I've been developing in the Java ecosystem in enterprise environments for years, and the use of Spring and SpringBoot is already widely adopted. I still remember the early days with Struts, JPA, EJB… but the arrival of Spring started solving common problems across applications (security, database access, auditing…), and it became a fairly standard solution in the backend world.
At the same time that the infrastructure we deploy on has evolved, we've lost a bit of focus on optimizations, both in terms of physical memory and RAM. We’ve reached fairly high numbers with images for a simple microservice, even though we're not really using all that software.
One of the alternatives gaining traction in enterprise environments is Quarkus. Although it's a relatively young project (2019), it keeps expanding its integrations, making it a viable option to base our applications on (within the Java ecosystem).
Why should I use Quarkus?
Quarkus is based on three fundamental pillars:
- Cloud Native. It simplifies application development with an optimized architecture, much faster boot times, and efficient resource usage.
- Microservices development. It supports MicroProfile and Jakarta, leverages industry standards, includes extensions (Kafka, gRPC, REST, OpenTelemetry…), and offers a better experience with hot reloading.
- Serverless. It can be deployed as serverless functions on major cloud providers (AWS Lambda, Azure Functions, and Google Cloud Functions).
If we compare Spring vs Quarkus, Spring has a gentler learning curve, greater maturity, and a wealth of configurations and libraries, which makes it slower to start and results in heavier executables and images. On the other hand, Quarkus significantly reduces memory usage, is lighter and faster, but lacks the community size and integration breadth that Spring offers.
Another advantage of Quarkus is its reactivity, which allows it to handle large volumes of requests without blocking threads, optimizing CPU and memory usage. Under the hood, it uses an event loop model based on Vert.x to avoid unnecessary blocking. It can also integrate with non-blocking messaging systems (like Kafka). The result is high-performance systems that are highly scalable and efficient.
I've done tests using the same microservice code to ensure a fair comparison (preserving the same functionality and not using native compilation). The image size is less than half, and the startup time drops from 20–30 seconds to 1–2 seconds. If we extrapolate this to a microservice ecosystem, we could significantly reduce operational costs (OPEX) and improve scalability.
Additionally, Quarkus is designed to generate native executables using GraalVM, which in my tests reduced CPU usage to 0.33% compared to a typical microservice running on a JDK image. How many resources could we save by migrating from Spring to Quarkus? Besides the direct impact on cost, more efficient cloud usage makes our solutions more sustainable and reduces their environmental footprint.
Ok, but… how do we migrate? Let’s try to create a short checklist using a sample app that exposes a REST endpoint and communicates with a database (although it’s not bulletproof).
Controller:
- Using JAX-RS. We switch to Java EE annotations such as @Path, @Get, @Post.
- Additionally, we can use the OpenAPI plugin to continue following an API First approach
@Api(description = "the Products API")
@Path("/products")
@jakarta.annotation.Generated(value = "org.openapitools.codegen.languages.JavaJAXRSSpecServerCodegen", date = "2025-02-27T10:46:38.175609+01:00[Europe/Madrid]")
public interface ProductsApi {
@POST
@Consumes({ "application/json" })
@Produces({ "application/json", "application/problem+json" })
@ApiOperation(value = "Method to add one product to the data storage", notes = "This method creates a new product with the given body information", authorizations = {
@Authorization(value = "adfs", scopes = {
})
}, tags={ "products" })
@ApiResponses(value = {
@ApiResponse(code = 201, message = "Created", response = ProductResponse.class),
@ApiResponse(code = 401, message = "Unauthorized", response = ProblemDetailsResponse.class) })
Response createProduct(@Valid @NotNull ProductRequest productRequest);
Persistence:
- We need to use Panache instead of SpringData.
- The concept is similar because we use PanacheRepository, which already provides many methods to operate with our entity.
- We can use JPQL, NativeQueries…
- We can still inject our entityManager and operate as usual.
- For auditing purposes, we can use
quarkus-hibernate-envers
.
- Another possibility to keep in mind for the future is the use of Jakarta Persistence, but for now, within Quarkus, the recommended approach is to use Panache.
@ApplicationScoped
public class ProductRepository implements PanacheRepository<ProductEntityModel> {
public Optional<ProductEntityModel> findByIdOptional(Long id) {
return find("id", id).firstResultOptional();
}
@Transactional
public ProductEntityModel save(ProductEntityModel product) {
persist(product);
return product;
}
@Transactional
public boolean deleteById(Long id) {
return ProductEntityModel.deleteById(id);
}
}
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class ProductEntityModel extends PanacheEntity {
private String name;
private String description;
Developing:
- Our Beans now use
@ApplicationScoped
(although other scopes are supported). - OpenTelemetry is native and handled at compile time simply by adding the dependency.
- Liveness and readiness probes are based on smallrye-health.
- We don't use
@Bean
annotations; instead, we use@Inject
, which is part of the standard. - We use different Maven goals, and hot reload is available.
- Designed for native compilation (as long as we’re not using libraries that rely heavily on reflection, it should work smoothly).
What if I want to stick with Spring?
It’s definitely not the end of the world, but updating Spring versions helps improve compatibility and makes it easier to generate native images. Spring is gradually removing reflection from its core.
If I want to optimize and can't use GraalVM due to compatibility issues, I can turn to Spring CDS (Class Data Sharing). This is a JVM optimization that allows preprocessed class data to be shared, reducing startup time and memory usage (this is separate from GraalVM). When run for the first time, a CDS file (a sort of cache) is created for the classes.
Startup time and memory usage are significantly reduced because the JVM can load these preprocessed classes directly on subsequent runs instead of reading from .jar files. This is ideal for Java 17/21 and SpringBoot 3.x or higher.
Here’s what it might look like:
java -XX:DumpLoadedClassList=app.classlist -jar ./target/springsample-0.0.1.jar
java -Xshare:dump -XX:SharedClassListFile=app.classlist -XX:SharedArchiveFile=app.jsa -jar ./target/springsample-0.0.1.jar
java -Xshare:on -XX:SharedArchiveFile=app.jsa -jar ./target/springsample-0.0.1.jar
In the end, as software developers, we should aim to reduce costs and build more sustainable software. Additionally, optimizing our resource usage can become a competitive advantage. Choosing one framework over another depends on many factors, such as the development team's experience, the size of the solution, and the company's context.
CDS is an incremental improvement within the JVM, while GraalVM represents a deeper reengineering that provides greater benefits—but also requires more adaptation.
Comments are moderated and will only be visible if they add to the discussion in a constructive way. If you disagree with a point, please, be polite.
Tell us what you think.