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 curve for Quarkus — subtle as it is — still exists. Spring Boot's documentation ecosystem is unmatched. The Stack Overflow corpus for Spring Boot is vast. Quarkus is catching up, but it is not there yet.
Existing Spring ecosystem investments. If you already use Spring Cloud Config, Spring Cloud Gateway, Spring Security OAuth2, Spring Batch, or Spring Cloud Data Flow, replacing individual components with Quarkus equivalents requires rewriting infrastructure code. The return on investment diminishes unless you also need the startup time or memory improvements.
Projects that need maximum library compatibility. Quarkus works by processing bytecode at build time. Most libraries work, but some use reflection, dynamic class loading, or runtime bytecode generation in ways that break the Quarkus build model. Spring Boot imposes no such restrictions — if a library runs on the JVM, it runs in Spring Boot. The Quarkus extension ecosystem is growing, but it is not comprehensive.
Monoliths that are not in containers. Quarkus's killer features — sub-second startup, low memory footprint, native compilation — matter most in containerized environments where you pay for idle. If you are deploying a monolith to a traditional server, Spring Boot's startup time is a one-time cost, and memory is cheaper than migration effort.
Where I Choose Quarkus Going Forward
Serverless and function-as-a-service workloads. Cold start is the enemy of serverless Java. Quarkus native images start in milliseconds. I have seen Spring Boot functions take 5–10 seconds to initialize on AWS Lambda cold starts. That is a non-starter for latency-sensitive APIs. Quarkus eliminates the problem entirely. The Quarkus AWS Lambda guide documents the integration.
Microservices where density matters. If you are running twenty microservices on a Kubernetes node with 8 GB of RAM, each one needs to be small. A Quarkus native binary uses 20–50 MB of RSS. A Spring Boot JAR on a JVM with the same service typically uses 200–400 MB. That is the difference between fitting on one node and needing three.
High-iteration development loops. I found the Quarkus dev mode genuinely faster — not incrementally faster, but categorically faster — than Spring Boot DevTools. If your team is doing rapid prototyping or frequent refactoring, the reduced cycle time adds up. Hot reload in Quarkus is sub-second for most changes.
Projects where build-time validation catches errors. Quarkus validates configuration and injection points at compile time, not at startup. A misconfigured @ConfigProperty or a missing bean dependency fails the build, not a production deployment. This is a cultural shift — you catch errors earlier in the pipeline — but it changes how you think about configuration.
Greenfield projects with a preference for standards over frameworks. JAX-RS, CDI, MicroProfile JWT, MicroProfile Fault Tolerance, and MicroProfile REST Client are Jakarta EE and MicroProfile standards. They are not tied to a single vendor or framework. If you value portability across runtimes, the standard-oriented approach is appealing. Spring Boot has been moving toward standards as well (Spring Data JPA is JPA, Spring Security supports OAuth2 standards), but Quarkus starts from the standard and builds out.
The Decision Matrix
| Criteria | Spring Boot | Quarkus |
|---|---|---|
| Startup time | Seconds to minutes | Milliseconds (JVM) / microseconds (native) |
| Memory (idle) | 200–400 MB per service | 20–50 MB per service (native) |
| Dev iteration speed | Fast (DevTools) | Faster (build-time processing + hot reload) |
| Library ecosystem | Comprehensive | Growing (extensions model) |
| Standards alignment | Spring conventions | Jakarta EE + MicroProfile specifications |
| Learning curve for Spring devs | None | Low (mapping is nearly one-to-one) |
| Serverless / FaaS | Struggles with cold start | First-class |
| Kubernetes density | Needs more resources | Efficient |
| Documentation corpus | Massive | Good and improving |
| Build-time validation | Limited | Extensive |
| Multi-module Maven/Gradle | Well supported | Well supported |
What I Used and What I Could Have Used
The project uses:
- Quarkus 3.15.2 with RESTEasy Reactive for the REST layer
- Hibernate ORM with Panache for persistence, active-record mode (Panache documentation)
- Flyway for schema migrations (Quarkus Flyway guide)
- SmallRye JWT for token issuance and validation (SmallRye JWT documentation)
- MicroProfile REST Client for inter-service HTTP calls (REST Client guide)
-
MicroProfile Fault Tolerance (
@Bulkhead,@RateLimit,@CircuitBreaker) for resilience (Fault Tolerance guide) - Vert.x Web routes for the API gateway (Reactive Routes guide)
- Micrometer + Prometheus for metrics (Micrometer guide)
- SmallRye OpenAPI / Swagger UI for API documentation (OpenAPI guide)
- Hibernate Validator for bean validation (Validation guide)
-
Quarkus Test with REST Assured and
@TestSecurityfor integration tests (Testing guide) - Docker Compose with multi-stage native-image Dockerfiles (Building Native Images guide)
I could have used instead:
- PanacheRepository instead of active-record mode if I wanted the repository abstraction
- Spring Cloud Gateway instead of Vert.x routes if I preferred YAML-based routing configuration
- Resilience4j instead of MicroProfile Fault Tolerance if I wanted to stay closer to the Spring ecosystem
- Gradle instead of Maven (the Quarkus plugin supports both equally)
-
Testcontainers via
@QuarkusTestResourcefor integration tests against real databases (Testcontainers guide) - Keycloak as an external identity provider instead of issuing JWTs internally (the README documents this as a planned migration path)
Final Thoughts
Quarkus does not replace Spring Boot. It complements it. Each framework has a strength profile, and the profiles overlap in the middle. If you are building a monolith with a well-known architecture, deploy to traditional servers, and have a team that knows Spring Boot — stay with Spring Boot. If you are building microservices for Kubernetes, caring about startup time, memory budget, and iteration speed — Quarkus is worth the investment.
The migration from Spring Boot to Quarkus is not a rewrite. It is a translation. The concepts map one-to-one. The annotations change names. The configuration keys change prefixes. But the patterns — dependency injection, REST endpoints, JPA persistence, JWT security, Flyway migrations — are the same patterns you already use.
I built this project to learn, and I learned that Java frameworks are converging on common patterns faster than the community rhetoric suggests. The gap between Spring Boot and Quarkus is smaller than the gap between either framework and the previous generation of Java enterprise frameworks. We are all moving in the same direction.
The full source code is available at github.com/ykpraveen/quarkus-panache-sample.
Top comments (0)