From Spring Boot to Quarkus
DEV Community

From Spring Boot to Quarkus

What I Learned Building a Microservices System

I have architected and developed many enterprise solutions using Spring Boot microservices for many years. Spring MVC, WebFlux, Integration, Batch, Data JPA, Security - that whole world is muscle memory. When I decided to try Quarkus, I expected it to be "Spring Boot on a diet." In some ways it is. In other ways, it is a genuinely different philosophy about what a Java framework should be.

I built a small microservices system: an identity service, a catalog service, an order service, and an API gateway - all backed by a single PostgreSQL instance with schema-level isolation, containerized with Docker Compose, and secured with JWT. The project lives at quarkus-panache-sample. Everything I describe below is drawn from that codebase.

This is not a Quarkus tutorial. It is a literature of discovery - the things that surprised me, the things that frustrated me, and the things that made me reconsider where I reach for which framework.

The First Surprise: Dev Mode Is Actually Fast

Spring Boot DevTools reloads the application context. It works, but when your project crosses a certain size, you wait. Quarkus takes a different path: build-time processing. Annotations are processed at compile time, not runtime. Reflection is minimized. CDI beans are wired ahead of time. The result is a development loop that genuinely feels iterative.

./mvnw quarkus:dev starts in under a second after the first build. Hot reload of Java classes, resources, and configurations happens without restarting the JVM. Spring Boot DevTools works, but I never felt the feedback loop was fast enough to keep flow state. Quarkus gets close.

# Catalog service config - Quarkus reads this at build time
quarkus.http.port = ${QUARKUS_HTTP_PORT:8082}
quarkus.datasource.jdbc.url = ${DB_URL:jdbc:postgresql://localhost:5432/platform}

The ${VAR:default} interpolation works the same as Spring's, but the processing model is fundamentally different. Quarkus resolves what it can at compile time. Spring Boot evaluates at startup, which gives you more dynamism but costs you startup time.

Panache vs Spring Data JPA: Same Destination, Different Vehicle

This was the most interesting comparison for me. Spring Data JPA uses the repository pattern: you define an interface, Spring generates the implementation. Quarkus offers two approaches through Panache: the active-record pattern (entity extends PanacheEntityBase) and the repository pattern (class implements PanacheRepository). I chose active record for this project.

Here is a product entity:

@Entity
@Table(name = "products", schema = "catalog")
public class ProductEntity extends PanacheEntityBase {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "catalog_products_seq_gen")
    @SequenceGenerator(name = "catalog_products_seq_gen", schema = "catalog",
                       sequenceName = "products_seq", allocationSize = 1)
    private Long id;

    @Column(nullable = false, length = 160)
    private String name;

    @Column(nullable = false, precision = 12, scale = 2)
    private BigDecimal price;

    // ... more fields

    public static List<ProductEntity> findActive(int page, int size) {
        return find("SELECT p FROM ProductEntity p LEFT JOIN FETCH p.category WHERE p.deletedAt IS NULL")
                .page(page, size).list();
    }
}

And here is the service that uses it:

@ApplicationScoped
public class CatalogService {

    @Bulkhead(10)
    public ProductResponse getProduct(Long productId) {
        ProductEntity product = ProductEntity.findById(productId);
        if (product == null || product.getDeletedAt() != null) {
            throw new ApiException(Response.Status.NOT_FOUND, "Product not found");
        }
        return toResponse(product);
    }

    @Bulkhead(5)
    @RateLimit(value = 20, window = 1, windowUnit = ChronoUnit.SECONDS)
    @Transactional
    public ProductResponse createProduct(CreateProductRequest request) {
        // ...
        product.persist();
        return toResponse(product);
    }
}

ProductEntity.findById(productId) is a static method. product.persist() is an instance method. No @Autowired JpaRepository<ProductEntity, Long> repository. The entity is the data access object.

In Spring Data JPA, the repository interface sits between the service and the entity. In Panache's active-record mode, that layer disappears entirely. I had mixed feelings about this. For simple CRUD, active record is concise and readable. For complex queries, it still supports the same HQL/JPQL you already know. But if you are used to the repository abstraction for testability and separation of concerns, Panache's repository mode is there too:

@ApplicationScoped
public class ProductRepository implements PanacheRepository<ProductEntity> {
    public List<ProductEntity> findActive(int page, int size) {
        return find("deletedAt IS NULL").page(page, size).list();
    }
}

I could have used this pattern instead. I chose active record because I wanted the full Quarkus experience. Both are well documented at Panache.

REST Resources: JAX-RS Feels Like Coming Home

Spring Boot uses @RestController, @RequestMapping, @GetMapping. Quarkus uses JAX-RS: @Path, @GET, @POST. If you have ever worked with JAX-RS before Spring Boot took over the world, this is familiar ground.

@Path("/api/v1/catalog")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class CatalogResource {

    @Inject
    CatalogService catalogService;

    @POST
    @Path("/products")
    @RolesAllowed("ADMIN")
    public Response createProduct(@Valid CreateProductRequest request) {
        ProductResponse product = catalogService.createProduct(request);
        return Response.status(Response.Status.CREATED).entity(product).build();
    }

    @GET
    @Path("/products/{productId}")
    @RolesAllowed({"USER", "ADMIN"})
    public ProductResponse getProduct(@PathParam("productId") Long productId) {
        return catalogService.getProduct(productId);
    }
}

@Inject instead of @Autowired. jakarta.ws.rs.core.Response instead of ResponseEntity. @PathParam instead of @PathVariable. The mapping is nearly one-to-one. Spring Boot drew from the same well - Spring MVC was inspired by JAX-RS, and over time the industry converged on Jakarta EE standards. Quarkus bet on the standard directly.

The @RolesAllowed annotation is the Jakarta EE security standard. In Spring Boot, you would write @PreAuthorize("hasRole('ADMIN')"). It accomplishes the same thing. The difference is that @RolesAllowed works with any Jakarta-compatible security implementation, not just Spring Security.

Exception Mapping Instead of Controller Advice

Spring Boot uses @ControllerAdvice with @ExceptionHandler for centralized error handling. Quarkus uses JAX-RS ExceptionMapper:

@Provider
public class ApiExceptionMapper implements ExceptionMapper<ApiException> {

    @Override
    public Response toResponse(ApiException exception) {
        return Response.status(exception.status())
                .type(MediaType.APPLICATION_JSON)
                .entity(new ApiError(exception.status().getStatusCode(), exception.getMessage()))
                .build();
    }
}

The @Provider annotation registers the mapper automatically. No explicit configuration needed. I found this cleaner than Spring's approach, where you typically need to remember to add @ControllerAdvice to a class and annotate individual methods. The tradeoff is that ExceptionMapper operates at the JAX-RS layer, not the HTTP layer, which means it catches exceptions from JAX-RS resource methods but not filters. For most services, that is sufficient. The Quarkus documentation on error handling covers the full picture.

The API Gateway: Vert.x Routes Instead of Spring Cloud Gateway

This was the most alien part. Spring Cloud Gateway uses a builder DSL or YAML configuration for routes. Quarkus offers Vert.x Web routes as a first-class feature:

@ApplicationScoped
public class ReactiveGatewayRoutes {

    @Route(path = "/api/v1/identity/*", methods = Route.HttpMethod.GET)
    @Route(path = "/api/v1/identity/*", methods = Route.HttpMethod.POST)
    void identity(RoutingContext ctx) {
        proxy(ctx, identityBaseUrl);
    }

    @Route(path = "/api/v1/catalog/*", methods = Route.HttpMethod.GET)
    void catalogGet(RoutingContext ctx) {
        if (requireRoles(ctx, "USER", "ADMIN")) return;
        proxy(ctx, catalogBaseUrl);
    }

    @Route(path = "/api/v1/orders", methods = Route.HttpMethod.POST)
    void ordersReadWrite(RoutingContext ctx) {
        if (requireRoles(ctx, "USER", "ADMIN")) return;
        proxy(ctx, orderBaseUrl);
    }
}

@Route is processed at build time into Vert.x route handlers. The RoutingContext gives you access to the request, response, headers, and body - all reactive. Proxying is done via WebClient:

private void proxy(RoutingContext ctx, String baseUrl) {
    var req = client.requestAbs(method, baseUrl + targetPath);
    String auth = ctx.request().getHeader("Authorization");
    if (auth != null) req.putHeader("Authorization", auth);
    String correlationId = ctx.get("correlationId");
    if (correlationId != null) req.putHeader("X-Correlation-Id", correlationId);
    req.send()
        .onSuccess(resp -> sendResponse(ctx, resp))
        .onFailure(err -> sendError(ctx, err));
}

I could have used Spring Cloud Gateway instead, but that would have meant running a separate Spring Boot application alongside the Quarkus services. Vert.x routes let me keep everything in one Quarkus binary. The tradeoff is that I had to write the proxying logic myself - Spring Cloud Gateway gives you uri: http://identity-service in YAML.

REST Clients and Fault Tolerance: MicroProfile Over Feign

For inter-service communication, Spring Boot developers reach for @FeignClient (from Spring Cloud OpenFeign) or WebClient. Quarkus uses MicroProfile REST Client:

@Path("/api/v1/catalog/products")
@RegisterRestClient(configKey = "catalog-api")
public interface CatalogProductClient {

    @GET
    @Path("/{id}")
    @Bulkhead(5)
    @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5,
                    delay = 5000, delayUnit = ChronoUnit.MILLIS)
    CatalogProductResponse getProduct(@PathParam("id") Long id);
}

The @RegisterRestClient annotation tells Quarkus to generate the implementation. The @Bulkhead and @CircuitBreaker annotations come from MicroProfile Fault Tolerance, not Resilience4j. They work the same way - configure limits, thresholds, delays - but they are specified by a Jakarta EE standard rather than a Spring-specific library. I could have used Resilience4j directly (it has no Spring dependency), but the MicroProfile annotations integrate natively with Quarkus's metadata scanning and build-time processing. The MicroProfile Fault Tolerance specification documents the full annotation set.

Tests: @QuarkusTest vs @SpringBootTest

Tests in Quarkus use @QuarkusTest. The equivalent of @WithMockUser is @TestSecurity:

@QuarkusTest
class CatalogResourceTest {

    @Test
    @TestSecurity(user = "admin", roles = {"ADMIN"})
    void fullCrud() {
        // REST Assured calls against the running Quarkus instance
        given()
            .contentType(ContentType.JSON)
            .body("{\"name\":\"Electronics\"}")
        .when()
            .post("/api/v1/catalog/categories")
        .then()
            .statusCode(201);
    }

    @Test
    void listProductsUnauthenticated() {
        given()
        .when()
            .get("/api/v1/catalog/products")
        .then()
            .statusCode(401);
    }
}

REST Assured is the default HTTP test client (Spring Boot also supports it, but TestRestTemplate is the traditional choice). Mocking CDI beans uses @InjectMock (Quarkus-specific), which is equivalent to Spring's @MockBean. The test framework boots Quarkus once per test class, not per method, which keeps test runs fast. I did not use Quarkus Mockito configuration, but the project includes quarkus-junit5-mockito for that purpose. The order service tests use @InjectMock to simulate the catalog REST client.

The Memory Problem That Started This All

Building native images with GraalVM is memory-intensive. Each Quarkus service consumes 4โ€“16 GB of RAM during compilation. Starting all four at once with docker compose -f docker-compose.yml -f docker-compose.native.yml up --build can overwhelm a development machine. The solution is incremental builds - one service at a time:

docker compose -f docker-compose.yml -f docker-compose.native.yml build identity-service
docker compose -f docker-compose.yml -f docker-compose.native.yml build catalog-service
docker compose -f docker-compose.yml -f docker-compose.native.yml build order-service
docker compose -f docker-compose.yml -f docker-compose.native.yml build api-gateway

Or skip compose and build the Docker image directly:

docker build -f identity-service/Dockerfile.native -t identity-service .

The native Dockerfiles in this project use the quay.io/quarkus/ubi9-quarkus-mandrel-builder-image with Mandrel (the GraalVM distribution maintained by the Quarkus team). The multi-stage build pattern mirrors what you would do for any compiled language - build in a heavy image, run in a minimal one:

FROM quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:jdk-21 AS build
# ... copy sources, compile with -Dquarkus.native.enabled=true

FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0
WORKDIR /work/
COPY --from=build /code/api-gateway/target/*-runner /work/application
CMD ["./application", "-Dquarkus.http.host=0.0.0.0"]

The resulting image is around 130 MB - comparable to a Go binary container. Startup time is under 100 milliseconds. If you have ever watched a Spring Boot application struggle through classpath scanning on a cold start in Kubernetes, you understand why this matters.

Where I Still Reach for Spring Boot

I built this system to learn Quarkus. I chose Quarkus for this project deliberately. But I am not abandoning Spring Boot. Here is where I still reach for Spring Boot:

  • Large teams with established conventions. Spring Boot has been the dominant enterprise Java framework for over a decade. If you are onboarding developers who know Spring Boot but not JAX-RS or CDI, the learning

Comments

No comments yet. Start the discussion.