Building a production-ready application has a ton of moving parts. Most of the time, developers create a new project using some sort of tool, like Maven archetypes, and then go from tutorial to tutorial piecing together everything that is needed for their application. This article tries to bring all the parts together and provide a single full reference to the work that needs to be done for a Quarkus application. Youu can find all the examples from this article on GitHub.
Tech stack for the Quarkus example
Here's a quick list of the technologies we will be using:
Project scope and getting started
Like any project, we need to start by defining our scope. The goal of this project is to build a customer API. We want to support the basic CRUD (create, read, update, and delete) functionality exposed via a REST API, saving the data to a relational database and publishing data changes to a messaging topic for external asynchronous consumption. The scope for this article is to get an API up and running with functional integration tests.
The following commands create your initial project and start the project in Quarkus dev mode. This provides a quick validation that the project has been successfully created and is ready to work.
$ mvn io.quarkus:quarkus-maven-plugin:1.13.3.Final:create \
-DprojectGroupId=dev.rhenergy.customer \
-DprojectArtifactId=customer-api \
-DclassName="dev.rhenergy.customer.CustomerResource" \
-Dpath="/api/customers"
$ cd customer-api
$ ./mvnw clean quarkus:dev
Open the project in your IDE of choice and let's get started.
Note: I have purposely omitted boilerplate code for this article: getters, setters, hashCode, equals and toString, most notably. Additionally, we want to focus a moment on a subtle consideration in our development effort: Keep one eye on your imports. Don't just start importing things. As I add imports, I consciously attempt to limit my exposure to third-party libraries, focusing on staying in the abstraction layers such as the MicroProfile abstractions. Remember that every library you import is now your responsibility to care for and feed.
Architecture layers: Resource, service, repository
I like to stick with a traditional Resource Service Repository layering pattern, shown in Figure 1. In this pattern, the Repository class returns an Entity object, which is tightly coupled to the underlying database structure. The Service class accepts and returns Domain objects and the Resource layer simply manages the REST concerns, possibly handling additional data transformations from the Domain object to a specific View object.

I also like to put everything that's related in the same package. In the past, I split out packages into the architectural layers, as follows:
dev.rhenergy.customer.repository dev.rhenergy.customer.repository.entity dev.rhenergy.customer.resource dev.rhenergy.customer.service
But as my microservices got much more focused on a single domain, I now just throw it all in the dev.rhenergy.customer package, as shown in Figure 2.

Quarkus application dependencies
We'll start our coding with some changes to the pom.xml file.
Yaml properties
The first thing we change is the way properties are managed. I like YAML better than properties files, so I add the following to pom.xml:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-config-yaml</artifactId>
</dependency>
Then, rename the application.propertiesfile to application.yaml.
Database: Flyway and Panache
Data interactions are going to be managed by the Quarkus Panache extension. We are also going to version our database schema using Flyway, which has a Quarkus extension. To get started, let's add the extensions to the pom.xml file:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-flyway</artifactId>
</dependency>
Flyway
Using Flyway, we can quickly put together our first table, the customer table.
Note: I am taking some liberties with this application. Should the email address be required? Should the phone number be required? Maybe, maybe not.
We will place our first Flyway SQL file in the normal deployment location, src/main/resources/db/migration/V1__customer_table_create.sql:
CREATE TABLE customer (
customer_id SERIAL PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
middle_name VARCHAR(100),
last_name VARCHAR(100) NOT NULL,
suffix VARCHAR(100),
email VARCHAR(100),
phone VARCHAR(100)
);
Note: There is much discussion about how schema changes should be rolled out in production. For now, we are simply going to let the embedded Flyway library migrate the changes within the application startup. If your application requires more advanced rollouts, such as blue-green or canary, you will need to split the pipelines to have the schema changes roll out independently and be backward compatible.
JPA with Panache
Based on the table created with Flyway, we will create our first Entity object. I use the Repository pattern because I like the extra Repository interface there. The following goes in a file named src/main/java/dev/rhenergy/customer/CustomerEntity.java:
@Entity(name = "Customer")
@Table(name = "customer")
public class CustomerEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "customer_id")
private Integer customerId;
@Column(name = "first_name")
@NotEmpty
private String firstName;
@Column(name = "middle_name")
private String middleName;
@Column(name = "last_name")
@NotEmpty
private String lastName;
@Column(name = "suffix")
private String suffix;
@Column(name = "email")
@Email
private String email;
@Column(name = "phone")
private String phone;
...
}
Some notes about the entities:
- I like to name all my Java Persistence API (JPA) entity classes with a suffix
Entity. They serve a purpose: to map back to the database tables. I always provide a layer of indirection betweenDomainobjects andEntityobjects because when it's missing, I've lost more time than the time I've spent creating and managing the data copying processes. - Because of the way the JPA creates the target object names, you have to explicitly put in the
@Entityannotation so your HQL queries don't have referenceCustomerEntity. - I like to explicitly name both the table and the columns with the
@Tableand@Columnannotations. Why? I've lost more time when a code refactor inadvertently breaks the assumed named contracts than the time it costs me to write a few extra annotations. - My database column names are snake_case and the entity's class variables are camelCase.
The repository interface follows:
@ApplicationScoped
public class CustomerRepository implements PanacheRepositoryBase<CustomerEntity, Integer> {
}
It looks simple, but it's got power in all the right places. The file is named CustomerRepository.java. The PanacheRepositoryBase interface fills out all the code shown in Figure 3.

Domain, then service, then resource
The customer domain object for this first version of the application is very simple. The following goes in a file named Customer.java:
public class Customer {
private Integer customerId;
@NotEmpty
private String firstName;
private String middleName;
@NotEmpty
private String lastName;
private String suffix;
@Email
private String email;
private String phone;
}
Entity-to-domain object mapping
We need to create mappings between the domain object and the entity object. For these purposes, we will use MapStruct. It requires us to add the actual dependency and to enhance the compiler plugin with configuration:
<mapstruct.version>1.4.2.Final</mapstruct.version>
...
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
...
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
The mapper itself is very simple because it's basically a one-to-one mapping between the objects. In the following code, from a file named CustomerMapper.java, note the additional componentModel = "cdi" definition. This allows the mappers to get injected:
@Mapper(componentModel = "cdi")
public interface CustomerMapper {
CustomerEntity toEntity(Customer domain);
Customer toDomain(CustomerEntity entity);
}
Exception handling
I usually create a single exception, extending RuntimeException, and then use that for all my exceptions based on custom logic and for wrapping checked exceptions. Because only a single exception can be thrown, if I need to customize the response later on in any way, I don't have to write tons of mappers. The following goes in a file named ServiceException.java:
public class ServiceException extends RuntimeException
public ServiceException(String message) {
super(message);
}
}
Build the Service class
We can now build out the Service class to handle the CRUD. The following is in a file named CustomerService.java:
@ApplicationScoped
public class CustomerService {
private final CustomerRepository customerRepository;
private final CustomerMapper customerMapper;
private final Logger logger;
public CustomerService(CustomerRepository customerRepository, CustomerMapper customerMapper, Logger logger) {
this.customerRepository = customerRepository;
this.customerMapper = customerMapper;
this.logger = logger;
}
public List<Customer> findAll(){
return customerRepository.findAll().stream()
.map(customerMapper::toDomain)
.collect(Collectors.toList());
}
public Optional<Customer> findById(Integer customerId) {
return customerRepository.findByIdOptional(customerId).map(customerMapper::toDomain);
}
@Transactional
public Customer save(Customer customer) {
CustomerEntity entity = customerMapper.toEntity(customer);
customerRepository.persist(entity);
return customerMapper.toDomain(entity);
}
@Transactional
public Customer update(Customer customer) {
if (customer.getCustomerId() == null) {
throw new ServiceException("Customer does not have a customerId");
}
Optional<CustomerEntity> optional = customerRepository.findByIdOptional(customer.getCustomerId());
if (optional.isEmpty()) {
throw new ServiceException(String.format("No Customer found for customerId[%s]", customer.getCustomerId()));
}
CustomerEntity entity = optional.get();
entity.setFirstName(customer.getFirstName());
entity.setMiddleName(customer.getMiddleName());
entity.setLastName(customer.getLastName());
entity.setSuffix(customer.getSuffix());
entity.setEmail(customer.getEmail());
entity.setPhone(customer.getPhone());
customerRepository.persist(entity);
return customerMapper.toDomain(entity);
}
}
Notice the @Transactional annotations for the save and update methods. This annotation as it stands is the default behavior, which is to create a new customer record or use an existing one. You will also notice the injected Logger in the constructor as well. The Logger injection is a handy way to include a Simple Logging Facade for Java (SLF4J) logger in your classes without have to cut and paste the same code. The LoggerProducer looks like this:
@Singleton
public class LoggerProducer {
@Produces
Logger createLogger(InjectionPoint injectionPoint) {
return LoggerFactory.getLogger(injectionPoint.getMember().getDeclaringClass().getName());
}
}
Build the Resource class
Now let's build out the Resource. To start us off, we use the OpenAPI spec to create a conforming REST API. Let's grab the Quarkus extension for that and put it in our pom.xml file:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
You can also add extensions via the Quarkus Maven plugin using the following command. I usually can't remember the names, so I just cut and paste from an example, but it's an option:
./mvnw quarkus:add-extension -Dextensions="quarkus-smallrye-openapi"
Because we are going to be serializing objects back and forth using JSON, we also need to add the extension to handle the JSON serialization and deserialization:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-jsonb</artifactId>
</dependency>
Object validation
We also use the Hibernate Bean Validation framework. This allows you to place @Valid annotations on the method arguments to trigger the beans' javax.validation.contraints annotations, such as @NotEmpty and @Email:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
Resource class
Here's the Resource class, with some footnotes. The code goes into the CustomerResource.java file.
@Path("/api/customers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CustomerResource {
private final CustomerService customerService;
private final Logger logger;
public CustomerResource(CustomerService customerService, Logger logger) {
this.customerService = customerService;
this.logger = logger;
}
@GET
@APIResponses(
value = {
@APIResponse(
responseCode = "200",
description = "Get All Customers",
content = @Content(mediaType = "application/json",
schema = @Schema(type = SchemaType.ARRAY, implementation = Customer.class)))
}
)
public Response get() {
return Response.ok(customerService.findAll()).build();
}
@GET
@Path("/{customerId}")
@APIResponses(
value = {
@APIResponse(
responseCode = "200",
description = "Get Customer by customerId",
content = @Content(mediaType = "application/json",
schema = @Schema(type = SchemaType.OBJECT, implementation = Customer.class))),
@APIResponse(
responseCode = "404",
description = "No Customer found for customerId provided",
content = @Content(mediaType = "application/json")),
}
)
public Response getById(@PathParam("customerId") Integer customerId) {
Optional<Customer> optional = customerService.findById(customerId);
return !optional.isEmpty() ? Response.ok(optional.get()).build() : Response.status(Response.Status.NOT_FOUND).build();
}
@POST
@APIResponses(
value = {
@APIResponse(
responseCode = "201",
description = "Customer Created",
content = @Content(mediaType = "application/json",
schema = @Schema(type = SchemaType.OBJECT, implementation = Customer.class))),
@APIResponse(
responseCode = "400",
description = "Customer already exists for customerId",
content = @Content(mediaType = "application/json")),
}
)
public Response post(@Valid Customer customer) {
final Customer saved = customerService.save(customer);
return Response.status(Response.Status.CREATED).entity(saved).build();
}
@PUT
@APIResponses(
value = {
@APIResponse(
responseCode = "200",
description = "Customer updated",
content = @Content(mediaType = "application/json",
schema = @Schema(type = SchemaType.OBJECT, implementation = Customer.class))),
@APIResponse(
responseCode = "404",
description = "No Customer found for customerId provided",
content = @Content(mediaType = "application/json")),
}
)
public Response put(@Valid Customer customer) {
final Customer saved = customerService.update(customer);
return Response.ok(saved).build();
}
}
Some notes about the code:
- The
@Producesand@Consumesannotations can be at the class level, rather than the method level, reducing duplication. - The
@APIResponsesdefinitions are a way to inline the Swagger documentation directly in the code. The annotations generate some noise, but reduce the need to maintain the implementation class separately from the Swagger definition. - Notice that all the methods actually return a
Responseobject. I find it much easier to manage theResponsedata, such as the HTTP status code. If you return an object itself, the framework will automatically wrap it in a 200 response code. This is fine on aGET, but when I have aPOST, I want to see that pretty 201 response code. - If you use the
Responseclass, the@ApiResponsesserve as the documentation of the actual payload returned in the body of the response. The OpenAPI extension autogenerates all the Swagger APIs and Swagger UI for you automatically.
Additional OpenAPI documentation
We can configure the OpenAPI Swagger API and UI in the application.yaml file as follows:
mp
openapi:
extensions:
smallrye:
info:
title: Customer API
version: 0.0.1
description: API for retrieving customers
contact:
email: techsupport@rhenergy.dev
name: Customer API Support
url: http://rhenergy.github.io/customer-api
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
Testing the application
Now we have the full stack in place. Let's get to the tests. First add the AssertJ library to the pom.xml file. Fluent assertions allow for more condensed test assertions and reduces the amount of repetitive code:
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
Now on to the test, in a file named CustomerResourceTest.java:
@QuarkusTest
public class CustomerResourceTest {
@Test
public void getAll() {
given()
.when().get("/api/customers")
.then()
.statusCode(200);
}
@Test
public void getById() {
Customer customer = createCustomer();
Customer saved = given()
.contentType(ContentType.JSON)
.accept(ContentType.JSON)
.body(customer)
.post("/api/customers")
.then()
.statusCode(201)
.extract().as(Customer.class);
Customer got = given()
.when().get("/api/customers/{customerId}", saved.getCustomerId())
.then()
.statusCode(200)
.extract().as(Customer.class);
assertThat(saved).isEqualTo(got);
}
@Test
public void post() {
Customer customer = createCustomer();
Customer saved = given()
.contentType(ContentType.JSON)
.accept(ContentType.JSON)
.body(customer)
.post("/api/customers")
.then()
.statusCode(201)
.extract().as(Customer.class);
assertThat(saved.getCustomerId()).isNotNull();
}
@Test
public void postFailNoFirstName() {
Customer customer = createCustomer();
customer.setFirstName(null);
given()
.contentType(ContentType.JSON)
.accept(ContentType.JSON)
.body(customer)
.post("/api/customers")
.then()
.statusCode(400);
}
@Test
public void put() {
Customer customer = createCustomer();
Customer saved = given()
.contentType(ContentType.JSON)
.accept(ContentType.JSON)
.body(customer)
.post("/api/customers")
.then()
.statusCode(201)
.extract().as(Customer.class);
saved.setFirstName("Updated");
Customer updated = given()
.contentType(ContentType.JSON)
.accept(ContentType.JSON)
.body(saved)
.put("/api/customers")
.then()
.statusCode(200)
.extract().as(Customer.class);
assertThat(updated.getFirstName()).isEqualTo("Updated");
}
@Test
public void putFailNoLastName() {
Customer customer = createCustomer();
Customer saved = given()
.contentType(ContentType.JSON)
.accept(ContentType.JSON)
.body(customer)
.post("/api/customers")
.then()
.statusCode(201)
.extract().as(Customer.class);
saved.setLastName(null);
given()
.contentType(ContentType.JSON)
.accept(ContentType.JSON)
.body(saved)
.put("/api/customers")
.then()
.statusCode(400);
}
private Customer createCustomer() {
Customer customer = new Customer();
customer.setFirstName(RandomStringUtils.randomAlphabetic(10));
customer.setMiddleName(RandomStringUtils.randomAlphabetic(10));
customer.setLastName(RandomStringUtils.randomAlphabetic(10));
customer.setEmail(RandomStringUtils.randomAlphabetic(10) + "@rhenergy.dev");
customer.setPhone(RandomStringUtils.randomNumeric(10));
return customer;
}
}
By no means do I consider this set of tests complete, but it does a decent job with the vanilla use cases. When you try to run the test, you'll quickly realize that we haven't wired up the needed database. We'll address that problem next.
Integration testing with Testcontainers
The 1.13 release of Quarkus includes a new set of tools known as devservices. With devservices, the Quarkus app automatically spins up a new Testcontainer for the appropriate database based on the inclusion of a specific quarkus-jdbc extension in the project. This wires up all the infrastructure needed to run a PostgreSQL container in the background when the JUnit tests start. But there's a missing piece: the application configuration. The following is a subset of the full application.yaml file, showing just the part related to the database:
quarkus:
banner:
enabled: false
datasource:
db-kind: postgresql
devservices:
image-name: postgres:13
hibernate-orm:
database:
generation: none
"%dev":
quarkus:
log:
level: INFO
category:
"dev.rhenergy":
level: DEBUG
hibernate-orm:
log:
sql: true
flyway:
migrate-at-start: true
locations: db/migration,db/testdata
"%test":
quarkus:
log:
level: INFO
category:
"dev.rhenergy":
level: DEBUG
hibernate-orm:
log:
sql: true
flyway:
migrate-at-start: true
locations: db/migration,db/testdata
The only adjustment made to the devservices is to specify the use of a particular Postgres container.
Now run your tests:
./mvnw clean test
The tests compile and start a fully running application. The tests are running against the actual HTTP endpoints.
Summary
Hopefully, this article gives you a good idea of the scope of building a new REST API from scratch. Future articles will go through the complete Quarkus development life cycle and show the full deployment to the production environment, with everything that entails. Happy coding.
Last updated: January 12, 2024