Chrispine Chiedo

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:

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 Java 21.

Create a new Quarkus project

There are three different ways of creating a new Quarkus project:

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:

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:

Application Architecture

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 through jq 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:

Swagger documentation

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:

Share on: