Tip

Build a microservice with Quarkus in 15 minutes

This hands-on Quarkus tutorial walks through building a reactive product catalog microservice with PostgreSQL, CRUD APIs, testing and fast deployment.

Organizations increasingly want fast, distributed Java applications that scale efficiently. The Quarkus framework is suited to this use case.

In this hands-on tutorial, we'll build a production-ready product catalogue microservice with the Quarkus framework. If you're familiar with Spring Boot and its runtime-heavy approach, you might learn something new in how to build and deploy Java applications.

The complete source code for this tutorial is on GitHub.

What we're building

The end result of this tutorial is to create a reactive REST API that manages a product catalog with PostgreSQL integration. To achieve this, we will build a microservice with the following functionalities:

  • Full CRUD operations for product management.
  • Database persistence with Hibernate ORM.
  • Search and filtering capabilities.
  • Comprehensive test coverage.

Prerequisites

Before we begin, ensure you have installed the following tools:

Tip: Use SDKMAN! to manage multiple JDK versions and ensure JAVA_HOME is properly configured. Download and install it, then run these commands:

sdk install java 21.0.5-tem
sdk install maven

Project setup

Our first step is to create the project structure. Create and navigate to your project directory:

mkdir product-catalog
cd product-catalog

Now, bootstrap the Quarkus project with the necessary extensions:

mvn io.quarkus.platform:quarkus-maven-plugin:3.20.0:create \
    -DprojectGroupId=ca.bazlur \
    -DprojectArtifactId=product-catalog \
    -DclassName="ca.bazlur.ProductResource" \
    -Dpath="/products" \
    -Dextensions="hibernate-orm-panache,resteasy-jackson,jdbc-postgresql"

Notice how we're adding extensions at creation time. Quarkus extensions provide pre-integrated capabilities with zero configuration overhead. The extensions we've added include:

  • hibernate-orm-panache. This simplifies JPA with the active record pattern.
  • resteasy-jackson. JAX-RS implementation with JSON support.
  • jdbc-postgresql. PostgreSQL database driver.

This command creates the following project structure:

product-catalog/
├── mvnw
├── mvnw.cmd
├── pom.xml
├── README.md
└── src/
    ├── main/
    │   ├── docker/
    │   │   ├── Dockerfile.jvm
    │   │   ├── Dockerfile.legacy-jar
    │   │   ├── Dockerfile.native
    │   │   └── Dockerfile.native-micro
    │   ├── java/
    │   │   └── ca/
    │   │       └── bazlur/
    │   │           ├── MyEntity.java
    │   │           └── ProductResource.java
    │   └── resources/
    │       ├── application.properties
    │       └── import.sql
    └── test/
        └── java/
            └── ca/
                └── bazlur/
                    ├── ProductResourceIT.java
                    └── ProductResourceTest.java

 

Obtain and test the initial REST endpoint

Quarkus generates a basic REST endpoint to get us started. Open ProductResource.java to see it:

package ca.bazlur;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/products")
public class ProductResource {

    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String hello() {
        return "Hello from RESTEasy";
    }
}

Let's test this endpoint. Start the application in development mode:

./mvnw quarkus:dev

Quarkus dev mode provides live reload, so any code changes are immediately reflected without restarting the application. This automated provisioning of services in dev and test modes is called Dev Services

Now we test the endpoint:

curl -i localhost:8080/products

And receive these headers in response:

HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
content-length: 18

And this in the body of the response:

Hello from RESTEasy

Create the product entity

Now let's create our product entity. Replace the generated MyEntity.java with a new Product.java:

package ca.bazlur;

import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;

@Entity
@Table(name = "products")
public class Product extends PanacheEntity { // ①

    @Column(nullable = false)
    public String name;

    @Column(length = 1000)
    public String description;

    @Column(nullable = false, precision = 10, scale = 2) // ②
    public BigDecimal price;

    @Column(nullable = false)
    public String category;

    @Column(name = "created_at", updatable = false) // ③
    public LocalDateTime createdAt;

    // Custom query methods
    public static List<Product> findByCategory(String category) { // ④
        return find("category", category).list();
    }

    public static List<Product> searchByName(String searchTerm) { // ⑤
        return find("LOWER(name) LIKE LOWER(?1)",
                    "%" + searchTerm + "%").list();
    }

    @PrePersist
    void onCreate() { // ⑥
        createdAt = LocalDateTime.now();
    }
}

Here is what we have done in the code, as flagged within the code above:

PanacheEntity provides the active record pattern with built-in CRUD operations and an auto-generated ID field.

BigDecimal with precision and scale ensures accurate monetary calculations without floating-point errors.

③ The updatable = false attribute ensures the creation timestamp is immutable after initial persistence.

④ Static finder methods provide type-safe queries without the need for a separate repository layer.

⑤ Case-insensitive search using LOWER() functions works across different databases.

⑥ JPA lifecycle callbacks automatically set timestamps without manual intervention.

The power of Panache

Panache is a Quarkus feature that simplifies database access in Java.

Those who work with Spring Data JPA might expect to write a repository interface for each entity. Panache takes a different approach that makes the entity a powerful active record with built-in query capabilities. Panache supports repository patterns, but there is no need for separate repository classes.

Here's what it provides out of the box with the record pattern:

// Basic CRUD operations
Product product = Product.findById(1L);
List<Product> products = Product.listAll();
long count = Product.count();
Product.deleteById(1L);

// Pagination support
PanacheQuery<Product> query = Product.findAll();
List<Product> firstPage = query.page(0, 20).list();

// Type-safe queries with parameters
List<Product> electronics = Product.list("category", "Electronics");
Product cheapest = Product.find("price < ?1", 50.0)
        .firstResult();

// Sorting
List<Product> sorted = Product.listAll(
        Sort.by("price").descending()
);

// Complex queries remain readable
List<Product> results = Product.find(
        "category = ?1 and price between ?2 and ?3",
        "Electronics", 20.0, 100.0
).list();

Build the REST API

Now let's create a full-featured REST API. Update ProductResource.java, as so:

package ca.bazlur;

import jakarta.transaction.Transactional;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;

import java.net.URI;
import java.util.List;

@Path("/products")
@Produces(MediaType.APPLICATION_JSON) // ①
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {

    @GET
    public List<Product> getAllProducts(
            @QueryParam("category") String category, // ②
            @QueryParam("search") String search) {

        if (category != null) {
            return Product.findByCategory(category);
        }

        if (search != null) {
            return Product.searchByName(search);
        }

        return Product.listAll();
    }

    @GET
    @Path("/{id}")
    public Response getProduct(@PathParam("id") Long id) { // ③
        Product product = Product.findById(id);
        if (product != null) {
            return Response.ok(product).build();
        }
        return Response.status(Response.Status.NOT_FOUND).build();
    }

    @POST
    @Transactional // ④
    public Response createProduct(Product product) {
        product.persist();
        URI location = UriBuilder.fromResource(ProductResource.class)
                .path("{id}")
                .build(product.id); // ⑤
        return Response.created(location)
                .entity(product)
                .build();
    }

    @PUT
    @Path("/{id}")
    @Transactional
    public Response updateProduct(@PathParam("id") Long id,
                                 Product product) {
        Product existing = Product.findById(id);
        if (existing == null) {
            return Response.status(Response.Status.NOT_FOUND).build();
        }

        existing.name = product.name; // ⑥
        existing.description = product.description;
        existing.price = product.price;
        existing.category = product.category;

        return Response.ok(existing).build();
    }

    @DELETE
    @Path("/{id}")
    @Transactional
    public Response deleteProduct(@PathParam("id") Long id) {
        boolean deleted = Product.deleteById(id); // ⑦
        if (deleted) {
            return Response.noContent().build();
        }
        return Response.status(Response.Status.NOT_FOUND).build();
    }
}

This REST resource demonstrates several important Jakarta RESTful Web Services and Quarkus patterns:

① Jakarta RESTful Web Service annotations at the class level apply to all methods, reducing repetition.

② Query parameters enable filtering and searching without separate endpoints.

③ Response objects provide fine-grained control over HTTP status codes and headers.

④ The @Transactional annotation ensures database operations are atomic, meaning they all succeed or all fail.

⑤ Location headers follow REST best practices by indicating where the created resource can be found.

⑥ Direct field access in Panache entities keeps the code concise and readable.

⑦ Boolean return values from Panache methods indicate operation success.

Database setup with Dev Services

One of Quarkus's most impressive features, as mentioned earlier, is Dev Services. To utilize this here, we add some test data to src/main/resources/import.sql:

INSERT INTO products (id, name, description, price, category, created_at)
VALUES 
    (1, 'Laptop Pro', 'High-performance laptop for professionals', 
     1499.99, 'Electronics', '2024-10-26T10:00:00'),
    (2, 'Wireless Mouse', 'Ergonomic wireless mouse with long battery life', 
     29.99, 'Accessories', '2024-10-26T10:05:00'),
    (3, 'Mechanical Keyboard', 'RGB mechanical keyboard with blue switches', 
     129.50, 'Accessories', '2024-10-25T14:30:00'),
    (4, '4K Monitor', '27-inch 4K UHD monitor with HDR support', 
     399.00, 'Electronics', '2024-10-26T11:15:00'),
    (5, 'Java Programming Book', 'Comprehensive guide to modern Java', 
     45.99, 'Books', '2024-10-24T09:00:00');

ALTER SEQUENCE products_SEQ RESTART WITH 6;

These insert queries will run during the startup.

Make sure Docker or Podman is running, then restart the application:

./mvnw quarkus:dev

That's it! Quarkus automatically does the rest:

  1. Detects that you need PostgreSQL.
  2. Starts a containerized PostgreSQL instance.
  3. Configures the data source.
  4. Creates the database schema.
  5. Loads the test data.

There's no need for Docker Compose files and no manual configuration. Just pure development productivity.

Now, test the API:

# Get all products
curl localhost:8080/products | jq

# Filter by category
curl "localhost:8080/products?category=Electronics" | jq

# Search by name
curl "localhost:8080/products?search=laptop" | jq

# Create a new product
curl -X POST localhost:8080/products \
  -H "Content-Type: application/json" \
  -d '{
    "name": "USB-C Hub",
    "description": "7-in-1 USB-C hub with HDMI",
    "price": 49.99,
    "category": "Accessories"
  }' | jq

Comprehensive testing with Quarkus

Quarkus makes testing straightforward. It uses a single annotation, @QuarkusTest. Unlike Spring Boot's various test slices (@WebMvcTest, @DataJpaTest, @SpringBootTest), Quarkus takes a unified approach. The @QuarkusTest annotation starts the entire application with Dev Services, providing real databases and services. We don’t need mocking or any H2 substitutes.

Update ProductResourceTest.java:

package ca.bazlur;

import io.quarkus.test.junit.QuarkusTest;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.core.MediaType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.*;

@QuarkusTest // ①
class ProductResourceTest {

    @BeforeEach
    @Transactional
    void cleanupDatabase() { // ②
        Product.deleteAll();
    }

    @Test
    void testGetAllProductsEmpty() {
        given()
            .when().get("/products")
            .then()
            .statusCode(200)
            .contentType(MediaType.APPLICATION_JSON)
            .body("$", empty()); // ③
    }

    @Test
    void testGetAllProductsWithData() {
        createTestProducts();

        given()
            .when().get("/products")
            .then()
            .statusCode(200)
            .contentType(MediaType.APPLICATION_JSON)
            .body("$", hasSize(3))
            .body("name", hasItems("Laptop", "Mouse", "Book")); // ④
    }

    @Test
    void testSearchProductsCaseInsensitive() {
        createTestProducts();

        given()
            .queryParam("search", "LAPTOP") // ⑤
            .when().get("/products")
            .then()
            .statusCode(200)
            .body("$", hasSize(1))
            .body("[0].name", equalTo("Laptop"));
    }

    @Test
    void testCreateProduct() {
        Product product = new Product();
        product.name = "New Product";
        product.description = "A new product description";
        product.price = new BigDecimal("49.99");
        product.category = "New Category";

        given()
            .contentType(MediaType.APPLICATION_JSON)
            .body(product)
            .when().post("/products")
            .then()
            .statusCode(201) // ⑥
            .header("Location", matchesPattern(".*/products/\\d+"))
            .body("id", notNullValue())
            .body("createdAt", notNullValue());
    }

    @Test
    void testUpdateProduct() {
        Long productId = createProduct("Original Product",
            "Original Description",
            new BigDecimal("19.99"), "Original Category");

        Product updatedProduct = new Product();
        updatedProduct.name = "Updated Product";
        updatedProduct.description = "Updated Description";
        updatedProduct.price = new BigDecimal("29.99");
        updatedProduct.category = "Updated Category";

        given()
            .contentType(MediaType.APPLICATION_JSON)
            .body(updatedProduct)
            .when().put("/products/" + productId)
            .then()
            .statusCode(200)
            .body("name", equalTo("Updated Product")); // ⑦

        // Verify persistence
        Product persisted = Product.findById(productId);
        assert persisted.name.equals("Updated Product");
    }

    @Test
    void testDeleteProduct() {
        Long productId = createProduct("Product to Delete",
            "Will be deleted",
            new BigDecimal("99.99"), "Test");

        given()
            .when().delete("/products/" + productId)
            .then()
            .statusCode(204); // ⑧

        // Verify deletion
        given()
            .when().get("/products/" + productId)
            .then()
            .statusCode(404);
    }

    @Transactional
    void createTestProducts() {
        createProduct("Laptop", "High-performance laptop",
            new BigDecimal("999.99"), "Electronics");
        createProduct("Mouse", "Wireless mouse",
            new BigDecimal("29.99"), "Electronics");
        createProduct("Book", "Programming book",
            new BigDecimal("49.99"), "Books");
    }

    @Transactional
    Long createProduct(String name, String description,
                       BigDecimal price, String category) {
        Product product = new Product();
        product.name = name;
        product.description = description;
        product.price = price;
        product.category = category;
        product.persist();
        return product.id;
    }
}

Our test class showcases Quarkus' testing best practices and REST-assured integration:

① The @QuarkusTest annotation starts the entire application with real services, not mocks.

② Database cleanup ensures test isolation without complex rollback strategies.

③ JSONPath expressions with $ to represent the root enable powerful JSON assertions.

④ Hamcrest matchers provide readable assertions for collections and complex objects.

⑤ Case-insensitive search testing verifies that our LOWER() SQL functions work correctly.

⑥ HTTP status code verification ensures REST conventions are followed.

⑦ Response body validation confirms the updated values were persisted.

⑧ The status code 204 No Content is the correct response for successful DELETE operations.

Now we run the tests:

./mvnw test

Run Quarkus in JVM mode

While we've been using dev mode so far, you can also build and run your application as a traditional JAR, like so:

# Build the application
./mvnw clean package

# Run the JAR
java -jar target/quarkus-app/quarkus-run.jar

Its output reveals Quarkus starts remarkably fast, in 1.310 seconds:

__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/

2025-06-15 08:29:34,378 INFO  [io.quarkus] (main) product-catalog 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.23.3) started in 1.310s. Listening on: http://0.0.0.0:8080
2025-06-15 08:29:34,382 INFO  [io.quarkus] (main) Profile prod activated.
2025-06-15 08:29:34,382 INFO  [io.quarkus] (main) Installed features: [agroal, cdi, hibernate-orm, hibernate-orm-panache, jdbc-postgresql, narayana-jta, resteasy, resteasy-jackson, smallrye-context-propagation, vertx]

Conclusion

In just 15 minutes, we've built a production-ready microservice that would typically take hours with traditional frameworks.

A N M Bazlur Rahman is a Java Champion and staff software developer at DNAstack. He is also founder and moderator of the Java User Group in Bangladesh.

 

Dig Deeper on Application development and design