The Tenant Chronicles – Building a Multi-Tenant Todo App with Quarkus
Learn how to isolate user data and simplify CRUD logic with discriminator-based multi-tenancy in Quarkus and no boilerplate, just clean, secure Java.
Welcome to the Kingdom of Quarkus, where Todos are sacred quests and users rule their own data realms. In this epic, you’ll build a multi-tenant Todo application with full CRUD capabilities, secure login, and tenant isolation powered by Hibernate’s discriminator-based multi-tenancy. Each user sees only their own quests. No dragons, just clean domain boundaries and clever Java magic. And you can take a quick peek at the project in my Github repository.
Summon the Project
Fire up your terminal and conjure your Quarkus project:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=multi-tenant-todo-app \
-DclassName="org.acme.todo.TodoResource" \
-Dpath="/api/todos" \
-Dextensions="rest-jackson, hibernate-orm-panache, jdbc-postgresql, elytron-security-properties-file, smallrye-openapi"
cd multi-tenant-todo-app
This command adds the following Quarkus extensions:
Rest-Jackson - For expressive, JSON-ready endpoints
Hibernate-orm-panache - ORM magic with less boilerplate
jdbc-postgresql - JDBC with PostgreSQL
elytron-security-properties-file - For basic authentication via properties files
smallrye-openapi - Swagger UI basically to make testing easier
Forge Your Configuration
Use PostgreSQL like a noble developer as Quarkus Dev Service. And while you are here make sure to configure security appropriately and add the basic auth information for Swagger so the UI can render them.
Edit src/main/resources/application.properties
:
quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=quarkus_test
quarkus.datasource.password=quarkus_test
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.multitenant=DISCRIMINATOR
quarkus.hibernate-orm.log.sql=true
quarkus.security.users.file.enabled=true
quarkus.security.users.file.users=users.properties
quarkus.security.users.file.roles=roles.properties
quarkus.security.users.file.realm-name=MyRealm
quarkus.security.users.file.plain-text=true
quarkus.smallrye-openapi.security-scheme=basic
quarkus.smallrye-openapi.security-scheme-name=user
quarkus.smallrye-openapi.auto-add-security=true
Add users to src/main/resources/users.properties
:
alice=alicepassword
bob=bobpassword
Then their roles in roles.properties
:
alice=user
bob=user
The Todo Entity
Rename MyEntity.java
to Todo.java
and add the following:
package org.acme.todo;
import org.hibernate.annotations.TenantId;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "todos")
public class Todo extends PanacheEntity {
public String title;
public boolean completed = false;
@TenantId
public String tenantId;
public Todo() {
}
public Todo(String title, String tenantId) {
this.title = title;
this.tenantId = tenantId;
}
}
Resolving the Realm
Introduce MyTenantResolver.java
:
package org.acme.todo;
import io.quarkus.hibernate.orm.runtime.tenant.TenantResolver;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
@RequestScoped
public class MyTenantResolver implements TenantResolver {
@Inject
SecurityIdentity securityIdentity;
@Override
public String getDefaultTenantId() {
return "UNKNOWN_TENANT";
}
@Override
public String resolveTenantId() {
if (securityIdentity != null && securityIdentity.isAnonymous() == false) {
return securityIdentity.getPrincipal().getName();
}
return getDefaultTenantId();
}
}
The Repository Scroll
In TodoRepository.java
:
package org.acme.todo;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class TodoRepository implements PanacheRepository<Todo> {
// Panache + Hibernate = magic. No extra code needed.
}
The Service of Scroll-Keepers
Create TodoService.java
:
package org.acme.todo;
import java.util.List;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class TodoService {
@Inject
TodoRepository todoRepository;
public List<Todo> getTodosForCurrentUser() {
return todoRepository.listAll();
}
public Optional<Todo> getTodoByIdForCurrentUser(Long id) {
return todoRepository.findByIdOptional(id);
}
@Transactional
public Todo createTodoForCurrentUser(Todo todoData) {
Todo todo = new Todo();
todo.title = todoData.title;
todo.completed = todoData.completed;
todo.tenantId = null; // Let Hibernate fill this
todoRepository.persist(todo);
return todo;
}
@Transactional
public Optional<Todo> updateTodoForCurrentUser(Long id, Todo data) {
return todoRepository.findByIdOptional(id).map(existing -> {
existing.title = data.title;
existing.completed = data.completed;
todoRepository.persist(existing);
return existing;
});
}
@Transactional
public boolean deleteTodoForCurrentUser(Long id) {
return todoRepository.deleteById(id);
}
}
Exposing the Realm's API
Modify TodoResource.java
:
package org.acme.todo;
import java.util.List;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/todos")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed("user")
public class TodoResource {
@Inject
TodoService todoService;
@GET
public List<Todo> getAll() {
return todoService.getTodosForCurrentUser();
}
@GET
@Path("/{id}")
public Response getById(@PathParam("id") Long id) {
return todoService.getTodoByIdForCurrentUser(id)
.map(todo -> Response.ok(todo).build())
.orElse(Response.status(Response.Status.NOT_FOUND).build());
}
@POST
public Response create(Todo todoData) {
if (todoData.title == null || todoData.title.isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"error\":\"Title cannot be empty\"}").build();
}
return Response.status(Response.Status.CREATED)
.entity(todoService.createTodoForCurrentUser(todoData)).build();
}
@PUT
@Path("/{id}")
public Response update(@PathParam("id") Long id, Todo todoData) {
if (todoData.title == null || todoData.title.isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"error\":\"Title cannot be empty\"}").build();
}
return todoService.updateTodoForCurrentUser(id, todoData)
.map(todo -> Response.ok(todo).build())
.orElse(Response.status(Response.Status.NOT_FOUND).build());
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") Long id) {
return todoService.deleteTodoForCurrentUser(id)
? Response.noContent().build()
: Response.status(Response.Status.NOT_FOUND).build();
}
}
Test Thy Quests
Compile the application. And while you’re here, delete the tests ;)
./mvnw compile
Start the app:
./mvnw quarkus:dev
Create Todos:
Make sure to create a base64-encoded string username:password
before you execute below command.
curl -X 'POST' \
'http://localhost:8080/api/todos' \
-H 'accept: */*' \
-H 'Authorization: Basic YWxpY2U6YWxpY2VwYXNzd29yZA==' \
-H 'Content-Type: application/json' \
-d '{"title":"Buy lembas bread"}'
Alternatively you can also use the Dev Service Swagger UI (http://localhost:8080/q/dev-ui/io.quarkus.quarkus-smallrye-openapi/swagger-ui
)and add the username and password there:
List Todos:
curl -X 'GET' \
'http://localhost:8080/api/todos' \
-H 'accept: */*' \
-H 'Authorization: Basic YWxpY2U6YWxpY2VwYXNzd29yZA=='
Attempt Cross-Realm Theft:
curl -X 'GET' \
'http://localhost:8080/api/todos/1' \
-H 'accept: */*' \
-H 'Authorization: Basic Ym9iOmJvYnBhc3N3b3Jk'
Returns: 404 Error: Not Found
The Lore of Multi-Tenancy
Behind the scenes, Hibernate + Quarkus do the heavy lifting:
@TenantId
on the entity tells Hibernate what column to use.TenantResolver
hooks into SecurityIdentity to grab the logged-in user.Panache and Hibernate filter every query automatically.
You write zero extra tenant filtering code.
Epilogue: Where to Go Next
Add JWT with
quarkus-smallrye-jwt
for modern authentication.Use Flyway or Liquibase for schema migrations.
Explore SCHEMA or DATABASE strategies for stricter isolation.
Add unit/integration tests with
@QuarkusTest
.
You have built a secure, multi-tenant Todo kingdom.
Each user sees only their data. No spying. No leaking. Just clean domains and fast Java magic.
Ready for your next quest? There’ll be a new post tomorrow!