Get started with Quarkus by building a simple REST API
Quarkus is a Kubernetes-native Java framework for building modern cloud-native applications. It is tailored for GraalVM and HotSpot, and crafted from best-of-breed Java libraries and standards. It was developed by Red Hat and released as an open-source project in 2019.
Since its release, the project has experienced steady growth and now has over 13K GitHub stars and recently celebrated a significant milestone of reaching over 1,000 community contributors — Yours truly is proud to be among the 1,000 contributors who were recognized by the Quarkus team.
The main features of Quarkus are:
- Container-first: Quarkus applications are optimised for low memory usage and fast startup times. This means that Quarkus is well suited for building cloud-native applications.
- Reactive core: Quarkus is based on a reactive core, which means that every Quarkus application is a reactive application. This makes Quarkus an excellent choice for building modern applications, from event-driven to function-as-a-service/serverless applications.
- Standards-based: The Quarkus programming model builds on top of official standards; for example, Eclipse MicroProfile and Jakarta EE.
- Kubernetes-native: Quarkus makes it easy to deploy microservice applications to Kubernetes without having to understand the intricacies of the underlying Kubernetes framework.
- Developer joy: Quarkus makes it trivial to develop simple applications, and easy to develop the more complex ones. It accomplishes this through features like live code reloading (while in dev mode), CLI tooling, Continuous Testing, and Dev Services.
In this tutorial, we’re going to build a simple employee API. The API will support all the standard CRUD (Create, Read, UUpdate, and Delete) operations, exposed via a REST API. The API will use a relational database (PostgreSQL) for data persistence.
All source code for this tutorial is available on GitHub.
Note: This tutorial uses Quarkus
3.15.1
and Java21
.
Create a new Quarkus project
There are three different ways of creating a new Quarkus project:
- Using the Quarkus CLI tool
- Using a build tool (Maven or Gradle)
- Using the Quarkus project starter at code.quarkus.io.
In this tutorial, we are going to use the first method (Quarkus CLI).
Visit this page to install the Quarkus CLI tool for your platform.
After installing the CLI tool, you can confirm that everything is working by running this command on your terminal:
$ quarkus --version
3.15.1
The command should print the installed version of the CLI tool (in this case 3.15.1
).
With that out of the way, we are now ready to start building our API.
Run the following command on your terminal to create the employee REST API project:
$ quarkus create app dev.chiedo.employee:employee-rest-api \
--extension='rest,jdbc-postgresql,hibernate-orm-panache,hibernate-validator,flyway,rest-jackson,smallrye-openapi,config-yaml'
Note: Feel free to replace
dev.chiedo
with your own domain string.
The command above will create a new directory named employee-rest-api
with the project’s starter code:
$ cd employee-rest-api
$ tree .
.
├── README.md
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
├── main
│ ├── docker
│ │ ├── Dockerfile.jvm
│ │ ├── Dockerfile.legacy-jar
│ │ ├── Dockerfile.native
│ │ └── Dockerfile.native-micro
│ ├── java
│ │ └── dev
│ │ └── chiedo
│ │ └── employee
│ │ ├── GreetingConfig.java
│ │ ├── GreetingResource.java
│ │ └── MyEntity.java
│ │
│ └── resources
│ ├── application.yml
│ └── import.sql
└── test
└── java
└── dev
└── chiedo
└── employee
├── GreetingResourceIT.java
└── GreetingResourceTest.java
13 directories, 15 files
Note: You can add the
--no-code
option to the previous command to exclude the starter code.
Application dependencies
In Quarkus, dependencies are usually called extensions
. When we ran the previous command to create our application, we also used the --extension
flag to add the required project dependencies:
Extension | Purpose |
---|---|
quarkus-rest | A Jakarta REST implementation for building RESTful web services. Uses Vert.x as its reactive core |
quarkus-jdbc-postgresql | Connect to PostgreSQL database via JDBC |
quarkus-hibernate-orm-panache | Data persistence and ORM capabilities |
quarkus-hibernate-validator | Validate object properties and method parameters for your beans |
quarkus-flyway | Handle database schema migrations |
quarkus-rest-jackson | Jackson serialization support for Quarkus REST |
quarkus-smallrye-openapi | Document REST APIs with OpenAPI - comes with Swagger UI |
quarkus-config-yaml | Use YAML to configure Quarkus application (instead of the default properties file) |
Note: You can also add individual extensions by using the following command:
$ quarkus extension add <name of extension>
For example:
$ quarkus extension add rest-jackson
The command above will automatically add the dependency to the
pom.xml
file.
A small refactoring exercise:
Before we continue, let’s make a few changes to the starter project:
-
In the
src/main/java/dev/chiedo/employee/
directory, apply the following changes:- delete the
GreetingConfig.java
file. We don’t need it for the application we are going to build - rename the
GreetingResource.java
file toEmployeeResource.java
- rename the
MyEntity.java
file toEmployeeEntity.java
- delete the
-
In the
src/main/resources/
directory, apply the following changes:- delete the
import.sql
file. We’ll be usingFlyway
for database schema migrations
- delete the
-
In the
src/test/java/dev/chiedo/employee/
directory, apply the following changes:- rename the
GreetingResourceTest.java
file toEmployeeResourceTest.java
- rename the
GreetingResourceIT.java
file toEmployeeResourceIT.java
- rename the
Note: You can use your IDE to perform safe refactoring.
Application architecture
For our application, we are going to use the Repository pattern, with a layered architecture as shown below:
As we are building a relatively simple application, all the architectural layers will live in the same domain package. In a typical layered architecture, we would create a subpackage to represent each of the different layers (presentation, business, persistence, etc).
Database: Create database schema migrations using Flyway
Flyway is a tool that helps with database versioning and schema migrations. Create the first Flyway migration file in the following location: src/main/resources/db/migration/V1__employee_table_create.sql
. The file contains the schema for the employee
database table.
File: src/main/resources/db/migration/V1__employee_table_create.sql
CREATE TABLE employee (
employee_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name VARCHAR(50) NOT NULL,
middle_name VARCHAR(50),
last_name VARCHAR(50) NOT NULL,
department VARCHAR(50),
email_address VARCHAR(50),
phone_number VARCHAR(50)
);
Here’s the second migration file that will populate the employee
table with some initial data:
File: src/main/resources/db/migration/V2__employee_table_data.sql
INSERT INTO employee (
first_name,
middle_name,
last_name,
department,
email_address,
phone_number) VALUES ('John', 'Doe', 'Brown', 'Engineering', 'john@example.com', '+1-722504507');
INSERT INTO employee (
first_name,
middle_name,
last_name,
department,
email_address,
phone_number) VALUES ('Ashley', 'William', 'Weber', 'Finance', 'ash@example.com', '+254-728504502');
Persistence layer: Data persistence using Hibernate ORM with Panache
Create the EmployeeEntity
class
An Entity class represents a table stored in the database. Every instance of an Entity class represents a row in a database table. Entity objects are tightly coupled to the underlying database structure. The mapping/conversion between entity objects and database tables is done using an ORM tool, like Hibernate ORM.
Here’s the EmployeeEntity
class:
File: src/main/java/dev/chiedo/employee/EmployeeEntity.java
// import statements omitted. Check project repo for full code
@Entity(name = "Employee")
@Table(name = "employee")
public class EmployeeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "employee_id")
private long employeeId;
@NotEmpty
@Column(name = "first_name")
private String firstName;
@Column(name = "middle_name")
private String middleName;
@NotEmpty
@Column(name = "last_name")
private String lastName;
@Column(name = "department")
private String department;
@Email
@Column(name = "email_address")
private String emailAddress;
@Column(name = "phone_number")
private String phoneNumber;
// getters, setters, equals, hashCode, toString methods elided...
}
Note: You can use a library like Lombok to reduce the amount of boilerplate code that you need to write.
Create the EmployeeRepository
class
File: src/main/java/dev/chiedo/employee/EmployeeRepository.java
@ApplicationScoped
public class EmployeeRepository implements PanacheRepositoryBase<EmployeeEntity, Long> {
}
By implementing the PanacheRepositoryBase
interface, the EmployeeRepository
class has access to many of the convenient methods defined in the PanacheRepositoryBase
interface.
Domain: Create the Employee
domain model object
The Employee
class represents the domain model object.
File: src/main/java/dev/chiedo/employee/Employee.java
// import statements omitted. Check project repo for full code
public class Employee {
private long employeeId;
@NotEmpty
private String firstName;
private String middleName;
@NotEmpty
private String lastName;
private String department;
@Email
private String emailAddress;
private String phoneNumber;
// getters, setters, equals, hashCode, toString methods elided...
}
Entity-to-domain object mapping
We need to create mappings between the domain
object and the entity
object. For this purpose, we will use MapStruct.
Add the MapStruct dependency and the corresponding compiler plugin configuration to the pom.xml
file:
File: pom.xml
<properties>
...
<org.mapstruct.version>1.6.0</org.mapstruct.version>
</properties>
...
<dependencies>
...
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.0</version>
</dependency>
</dependencies>
<build>
</plugins>
...
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
Here’s the mapper itself that provides a one-to-one mapping between the domain and entity objects:
File: src/main/java/dev/chiedo/employee/EmployeeMapper.java
package dev.chiedo.employee;
import org.mapstruct.Mapper;
@Mapper(componentModel = "cdi")
public interface EmployeeMapper {
EmployeeEntity toEntity(Employee domain);
Employee toDomain(EmployeeEntity entity);
}
Note: The mapper is annotated with
@Mapper(componentModel = "cdi")
. This allows the mapper to get injected wherever its needed.
Business layer: Create the EmployeeService
class
The Service
class handles the business logic of our application. In the case of an API, this means handling of CRUD operations. The Service class accepts and returns
Domain
objects.
Here’s the EmployeeService
class:
File: src/main/java/dev/chiedo/employee/EmployeeService.java
// import statements omitted. Check project repo for full code
@ApplicationScoped
public class EmployeeService {
private final EmployeeRepository employeeRepository;
private final EmployeeMapper employeeMapper;
// constructor injection
public EmployeeService(EmployeeRepository employeeRepository, EmployeeMapper employeeMapper) {
this.employeeRepository = employeeRepository;
this.employeeMapper = employeeMapper;
}
public List<Employee> findAll() {
return employeeRepository.findAll().stream()
.map(employeeMapper::toDomain)
.collect(Collectors.toList());
}
public Optional<Employee> findById(long employeeId) {
return employeeRepository.findByIdOptional(employeeId).map(employeeMapper::toDomain);
}
@Transactional
public void save(Employee employee) {
EmployeeEntity employeeEntity = employeeMapper.toEntity(employee);
employeeRepository.persist(employeeEntity);
}
@Transactional
public void update(long employeeId, Employee employee) {
Optional<EmployeeEntity> optionalEmployeeEntity = employeeRepository.findByIdOptional(employeeId);
if (optionalEmployeeEntity.isEmpty()) {
throw new NotFoundException(String.format("No Employee found with employeeId[%s]", employee.getEmployeeId()));
}
EmployeeEntity employeeEntity = optionalEmployeeEntity.get();
employeeEntity.setEmployeeId(employeeId);
employeeEntity.setFirstName(employee.getFirstName());
employeeEntity.setMiddleName(employee.getMiddleName());
employeeEntity.setLastName(employee.getLastName());
employeeEntity.setDepartment(employee.getDepartment());
employeeEntity.setEmailAddress(employee.getEmailAddress());
employeeEntity.setPhoneNumber(employee.getPhoneNumber());
employeeRepository.persist(employeeEntity);
}
@Transactional
public void delete(Employee employee) {
EmployeeEntity employeeEntity = employeeMapper.toEntity(employee);
employeeRepository.delete(employeeEntity);
}
}
Notice the @Transactional
annotation for the save
, update
, and delete
methods. This annotation is required as these actions are actually modifying the underlying database.
Presentation layer: Create the EmployeeResource
class
The Resource
layer mainly manages REST concerns (accepting HTTP requests and returning responses).
The Resource
class is similar to the Controller
class in Spring Boot.
Here’s the EmployeeResource
class:
File: src/main/java/dev/chiedo/employee/EmployeeResource.java
// import statements omitted. Check project repo for full code
@Path("/api/v1/employees")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class EmployeeResource {
private static final Logger LOGGER = LoggerFactory.getLogger(EmployeeResource.class);
private final EmployeeService employeeService;
// constructor injection
public EmployeeResource(EmployeeService employeeService) {
this.employeeService = employeeService;
}
@GET
@Operation(summary = "Returns all existing employees")
@APIResponses(
value = {
@APIResponse(
responseCode = "200",
description = "Get All Employees",
content = @Content(mediaType = "application/json",
schema = @Schema(type = SchemaType.ARRAY, implementation = Employee.class))),
@APIResponse(
responseCode = "404",
description = "No employees found",
content = @Content(mediaType = "application/json"))
}
)
public Response getAllEmployees() {
return Response.ok(employeeService.findAll()).build();
}
@GET
@Path("/{employeeId}")
@Operation(summary = "Returns an employee given the employee Id")
@APIResponses(
value = {
@APIResponse(
responseCode = "200",
description = "Get Employee by employeeId",
content = @Content(mediaType = "application/json",
schema = @Schema(type = SchemaType.OBJECT, implementation = Employee.class))),
@APIResponse(
responseCode = "404",
description = "No Employee found for the employeeId provided",
content = @Content(mediaType = "application/json"))
}
)
public Response getEmployeeById(@PathParam("employeeId") Long employeeId) {
Optional<Employee> optionalEmployee = employeeService.findById(employeeId);
if (optionalEmployee.isPresent()) {
LOGGER.info("Found employee {}", optionalEmployee);
return Response.ok(optionalEmployee.get()).build();
} else {
LOGGER.debug("No employee found with id {}", employeeId);
return Response.status(Response.Status.NOT_FOUND).build();
}
}
@POST
@Operation(summary = "Adds a new employee")
@APIResponses(
value = {
@APIResponse(
responseCode = "201",
description = "Employee created successfully",
content = @Content(mediaType = "application/json",
schema = @Schema(type = SchemaType.OBJECT, implementation = Employee.class))),
@APIResponse(
responseCode = "400",
description = "Employee already exists with employeeId",
content = @Content(mediaType = "application/json")),
}
)
public Response createEmployee(@RequestBody(required = true) @Valid Employee employee) {
employeeService.save(employee);
URI employeeUrl = URI.create("/api/v1/employees/" + employee.getEmployeeId());
LOGGER.info("New employee added at URL {}", employeeUrl);
return Response.created(employeeUrl).build();
}
// update and delete endpoints are omitted. Check project repo for full code
}
Application configuration properties
We specify our application’s configuration properties in the application.yml
file:
File: src/main/resources/application.yml
quarkus:
banner:
enabled: false
datasource:
db-kind: postgresql
devservices:
image-name: postgres:16
hibernate-orm:
database:
generation: none
"%dev":
quarkus:
log:
level: INFO
category:
"dev.chiedo":
level: DEBUG
hibernate-orm:
log:
sql: true
flyway:
migrate-at-start: true
locations: db/migration,db/testdata
mp:
openapi:
extensions:
smallrye:
info:
title: Employee API
version: 0.0.1
description: REST API for employees data
Instead of running a database instance locally, Quarkus provides a set of tools known as devservices. With Dev Services, the Quarkus app automatically spins up a new Testcontainer for the appropriate database based on the inclusion of a specific quarkus-jdbc
extension in your project (in our case, it’s quarkus-jdbc-postgresql
). This wires up all the infrastructure needed to run a PostgreSQL container in the background.
Note:
Dev Services
requires a working container runtime (Docker or Podman) to be installed on your machine.
For the quarkus
property settings, we specify the container image (postgres:13
) that Dev Services should use when spinning up a database instance for our application. We also set properties specific to the dev
mode, for example: log
levels and flyway
schema migration behaviour.
The last section has OpenAPI/Swagger documentation settings.
Run the application
At this point we have a fully functional employee REST API application.
From the terminal, run the following command to start the application:
$ quarkus dev
The command above will start the application in dev
(development) mode.
We can use the curl
tool (as a REST client) to interact with the API:
$ curl localhost:8080/api/v1/employees | jq
[
{
"employeeId": 1,
"firstName": "John",
"middleName": "Doe",
"lastName": "Brown",
"department": "Engineering",
"emailAddress": "john@example.com",
"phoneNumber": "+1-722504507"
},
{
"employeeId": 2,
"firstName": "Ashley",
"middleName": "William",
"lastName": "Weber",
"department": "Finance",
"emailAddress": "ash@example.com",
"phoneNumber": "+254-728504502"
}
]
Note that we are piping the output from
curl
throughjq
to get a well formatted output.
We can get a single employee by running the following command:
$ curl localhost:8080/api/v1/employees/1 | jq
{
"employeeId": 1,
"firstName": "John",
"middleName": "Doe",
"lastName": "Brown",
"department": "Engineering",
"emailAddress": "john@example.com",
"phoneNumber": "+1-722504507"
}
We can add a new employee by running the following command:
$ curl -X POST -H "Content-Type: application/json" -d '{"firstName" : "William" , "middleName" : "Davis", "lastName" : "Monroe", "department" : "Training", "emailAddress" : "will@example.com", "phoneNumber" : "+1-77501417"}' localhost:8080/api/v1/employees
If we run the command to get all employees again, you’ll see that we have the newly added employee:
$ curl localhost:8080/api/v1/employees | jq
[
{
"employeeId": 1,
"firstName": "John",
"middleName": "Doe",
"lastName": "Brown",
"department": "Engineering",
"emailAddress": "john@example.com",
"phoneNumber": "+1-722504507"
},
{
"employeeId": 2,
"firstName": "Ashley",
"middleName": "William",
"lastName": "Weber",
"department": "Finance",
"emailAddress": "ash@example.com",
"phoneNumber": "+254-728504502"
},
{
"employeeId": 3,
"firstName": "William",
"middleName": "Davis",
"lastName": "Monroe",
"department": "Training",
"emailAddress": "will@example.com",
"phoneNumber": "+1-77501417"
}
]
You can test the other endpoints (update and delete) on your own.
Note: Apart from
curl
, you can use other tools to interact with the API, for example: HTTPie or Postman.
Check Swagger API documentation
Visit localhost:8080/q/swagger-ui
on your browser to see the Swagger documentation for the API:
Write tests
As we are going to use assertions in our tests, we need to add the AssertJ library to the pom.xml
file:
File: pom.xml
...
<dependencies>
...
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
...
Here are simple examples of tests that we can write for our API:
File: src/test/java/dev/chiedo/employee/EmployeeResourceTest.java
// import statements omitted. Check project repo for full code
@QuarkusTest
public class EmployeeResourceTest {
private Employee createNewEmployee() {
Employee employee = new Employee();
employee.setFirstName(RandomStringUtils.randomAlphabetic(10));
employee.setMiddleName(RandomStringUtils.randomAlphabetic(10));
employee.setLastName(RandomStringUtils.randomAlphabetic(10));
employee.setDepartment(RandomStringUtils.randomAlphabetic(10));
employee.setEmailAddress(RandomStringUtils.randomAlphabetic(10) + "@example.com");
employee.setPhoneNumber(RandomStringUtils.randomNumeric(10));
return employee;
}
@Test
public void testGetAllEmployeesEndpointReturnsStatusCode200() {
given()
.when().get("/api/v1/employees")
.then()
.statusCode(200);
}
@Test
public void testCreateEmployeeFailsWithMissingFirstName() {
Employee employee = createNewEmployee();
employee.setFirstName(null);
given()
.contentType(ContentType.JSON)
.accept(ContentType.JSON)
.body(employee)
.post("/api/v1/employees")
.then()
.statusCode(400);
}
}
In order to run our tests, we need to add test configuration settings to the
application.yml
file:
File: src/main/resources/application.yml
// rest of file contents omitted...
"%test":
quarkus:
log:
level: INFO
category:
"dev.chiedo":
level: DEBUG
hibernate-orm:
log:
sql: true
flyway:
migrate-at-start: true
locations: db/migration,db/testdata
To run the test(s) use the following command:
$ quarkus test
Alternatively, you can use:
$ ./mvnw test
Summary
This tutorial was a high-level introduction to the Quarkus framework. We’ve successfully built a simple REST API and learnt some important API development concepts along the way.
Resources
Here are other resources you can check out: