How to Build a Multi-Role, Multi-Content API in Quarkus (Without DTOs)
A hands-on guide to combining Jackson Views, polymorphic entities, and Quarkus serialization for clean, scalable API design.
Modern enterprise APIs often suffer from the “DTO explosion” problem. Every team wants its own view: public, private, admin, moderator, analytics, partner, premium. The result is dozens of DTOs representing the same domain object.
This tutorial shows you how to build a Zero-DTO, Multi-Tenant Social API in Quarkus using Jackson Views and Panache entities. You keep recursion under control, avoid redundant mapping layers, and still return the exact shape the caller is allowed to see.
Quarkus makes this clean because REST sits on top of Jackson, giving you fast serialization with build-time analysis when compiling to native images.
Let’s build it step by step.
Prerequisites
You need:
JDK 21 or later
Maven or Gradle
Quarkus CLI (optional, recommended)
Podman (or even Docker) for the PostgreSQL Dev Service
Create the Project
We bootstrap a Quarkus app with REST + Jackson and Hibernate ORM with Panache. For the database we use PostgreSQL to keep the tutorial simple.
quarkus create app com.example:shapeshifter-api \
--extension='rest-jackson, hibernate-orm-panache, jdbc-postgresql'
cd shapeshifter-apiThis produces a lightweight structure ready for REST endpoints and JPA entities.
With the project created, we now define the “view system” that lets us shape API responses without creating DTOs. And if you don’t want to follow along, feel free to just grab the source from my GitHub repository.
Define Your View Hierarchy
The view classes represent lenses that determine which fields become visible to which callers. This replaces dozens of DTOs and becomes the backbone of your shapeshifting API. Java classes cannot support multiple inheritance. Interfaces can. let’s build a view hierarchy.
Admin extends Moderator AND PremiumFeatureModerator extends AuthenticatedPremiumFeaturecan be mixed into any role without duplicating logic
src/main/java/com/example/json/Views.java
package com.example.json;
public class Views {
// 1. The Building Blocks
public interface Public {
}
public interface PremiumFeature {
} // The “Aspect”
// 2. The Hierarchy
public interface Authenticated extends Public {
}
// 3. The Admin Features
public interface AdminFeature extends Authenticated {
}
// 4. The “Bridge” View
// This is the Magic: It combines standard access with premium features
// WITHOUT giving them Admin privileges.
public interface Subscriber extends Authenticated, PremiumFeature {
}
// 5. The Admin Feature (who sees everything)
public interface Admin extends PremiumFeature, AdminFeature {
}
}Now we have a view model that supports complex role and feature combinations without extra code.
Next we bring this power into the domain layer using polymorphism.
Create a Polymorphic Content Model
Our domain is simple: users write posts, and both entities contain fields that different roles can or cannot see.
We must solve recursion first.
A User contains List<Post> and a Post contains User author. Jackson would loop forever unless we tell it how to handle identity.
We fix this with:
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = “id”)It replaces repeated objects with their IDs.
We build an abstract Content base class and two subclasses:
TextPostVideoPost
To help frontends choose the right UI component, we annotate the hierarchy with @JsonTypeInfo, adding a “type” field such as “text” or “video”.
We also introduce a calculated premium field (getAdRevenue) exposed only to interfaces extending PremiumFeature.
Base class
src/main/java/com/example/entity/Content.java
package com.example.entity;
import java.math.BigDecimal;
import com.example.json.Views;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonView;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Entity;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.Transient;
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = “content_type”)
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = “type”
)
@JsonSubTypes({
@JsonSubTypes.Type(value = TextPost.class, name = “text”),
@JsonSubTypes.Type(value = VideoPost.class, name = “video”)
})
public abstract class Content extends PanacheEntity {
@JsonView(Views.Public.class)
public String title;
@JsonView(Views.Public.class)
public String author;
@JsonView(Views.Authenticated.class)
public int views;
// Premium-only calculated field
@JsonView(Views.PremiumFeature.class)
@JsonProperty(”adRevenue”)
@Transient // Not in DB, calculated on the fly
public BigDecimal getAdRevenue() {
return BigDecimal.valueOf(this.views * 0.05);
}
}TextPost entity
src/main/java/com/example/entity/TextPost.java
package com.example.entity;
import com.example.json.Views;
import com.fasterxml.jackson.annotation.JsonView;
import jakarta.persistence.Entity;
@Entity
public class TextPost extends Content {
@JsonView(Views.Public.class)
public String body;
@JsonView(Views.Public.class)
public int wordCount;
}VideoPost entity
The VideoPost has a function that is only useful for Premium users. For example, since Premium users get the high-bitrate “4k” file, let’s calculate the estimated download size for them.
src/main/java/com/example/entity/VideoPost.java
package com.example.entity;
import com.example.json.Views;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonView;
import jakarta.persistence.Entity;
import jakarta.persistence.Transient;
@Entity
public class VideoPost extends Content {
@JsonView(Views.Public.class)
public String videoUrl;
@JsonView(Views.Public.class)
public int durationSeconds;
@JsonView(Views.PremiumFeature.class)
public String bitrate;
@JsonView(Views.AdminFeature.class)
public String fsk;
// THE DYNAMIC CALCULATION
// Calculated on the fly. Only serialized if the view allows ‘PremiumFeature’.
@JsonView(Views.PremiumFeature.class)
@JsonProperty(”fileSize”)
@Transient // Not in DB
public String getEstimatedFileSize() {
if (bitrate != null && “4k”.equals(bitrate)) {
return (durationSeconds * 50) + “ MB”; // Mock calculation
}
return (durationSeconds * 5) + “ MB”;
}
}Our model is now polymorphic and view-driven.
Next we expose endpoints that return dynamically shaped JSON depending on the active view.
Build the Dynamic Resource
Quarkus fully supports @JsonView for static views like public, owner, moderator, admin.
The controller doesn’t need special logic. Choosing a view class in @JsonView automatically tells Jackson which fields to serialize.
src/main/java/com/example/resource/ContentResource.java
package com.example.resource;
import java.util.List;
import com.example.entity.Content;
import com.example.json.Views;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/api/content”)
@Produces(MediaType.APPLICATION_JSON)
public class ContentResource {
@Inject
ObjectMapper objectMapper;
@GET
public Response listContent(@QueryParam(”role”) String role) {
List<Content> contentList = Content.listAll();
Class<?> viewClass = determineView(role);
return serializeWithView(contentList, viewClass);
}
@GET
@Path(”/{id}”)
public Response getContent(@PathParam(”id”) Long id,
@QueryParam(”role”) String role) {
Content content = Content.findById(id);
if (content == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity(”{\”error\”:\”Content not found\”}”)
.type(MediaType.APPLICATION_JSON)
.build();
}
Class<?> viewClass = determineView(role);
return serializeWithView(content, viewClass);
}
private Response serializeWithView(Object data, Class<?> viewClass) {
try {
String json = objectMapper
.writerWithView(viewClass)
.writeValueAsString(data);
return Response.ok(json, MediaType.APPLICATION_JSON).build();
} catch (JsonProcessingException e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(”{\”error\”:\”Failed to serialize content\”}”)
.type(MediaType.APPLICATION_JSON)
.build();
}
}
private Class<?> determineView(String role) {
if (role == null) {
return Views.Public.class;
}
return switch (role.toLowerCase()) {
case “subscriber” -> Views.Subscriber.class;
case “admin” -> Views.Admin.class;
default -> Views.Public.class;
};
}
}With the controller ready, we configure Quarkus so that Jackson doesn’t fail when certain fields are filtered out by the active view.
Add Basic Configuration
src/main/resources/application.properties
quarkus.hibernate-orm.schema-management.strategy = drop-and-create
quarkus.jackson.fail-on-empty-beans = falseThis keeps serialization stable even when a view hides all fields of a class.
Seed Data
src/main/resources/import.sql
-- Seed polymorphic content data
-- TextPost
insert into Content (id, content_type, title, author, views, body, wordCount)
values(1, ‘TextPost’, ‘My Thoughts’, ‘Alice’, 1000, ‘Quarkus is fast.’, 3);
-- VideoPost
insert into Content (id, content_type, title, author, views, videoUrl, durationSeconds, bitrate, fsk)
values(2, ‘VideoPost’, ‘Funny Cat’, ‘Bob’, 50000, ‘http://vid.eo/cat’, 60, ‘4k’, ‘12’);Hibernate is going to load the data right after application start. A quick and simple way to see data for development.
Verification
Start the application:
quarkus devPublic View
curl 'http://localhost:8080/api/content'You see polymorphism and public-only fields:
[
{
“id”: 1,
“title”: “My Thoughts”,
“author”: “Alice”,
“body”: “Quarkus is fast.”,
“wordCount”: 3
},
{
“id”: 2,
“title”: “Funny Cat”,
“author”: “Bob”,
“videoUrl”: “http://vid.eo/cat”,
“durationSeconds”: 60
}
]Subscriber User View
Subscribed Users automatically see premium fields:
adRevenue(calculated per view)bitrate(premium-only field)fileSize(calculated per view))
curl 'http://localhost:8080/api/content?role=subscriber'See additional premium fields:
[
{
“id”: 1,
“title”: “My Thoughts”,
“author”: “Alice”,
“views”: 1000,
“body”: “Quarkus is fast.”,
“wordCount”: 3,
“adRevenue”: 50.0
},
{
“id”: 2,
“title”: “Funny Cat”,
“author”: “Bob”,
“views”: 50000,
“videoUrl”: “http://vid.eo/cat”,
“durationSeconds”: 60,
“bitrate”: “4k”,
“fileSize”: “3000 MB”,
“adRevenue”: 2500.0
}
]Admin User View
Admin Users also see AdminFeatures:
fsk(AdminFeature)
curl 'http://localhost:8080/api/content?role=admin'See additional admin-feature field:
[
{
“id”: 1,
“title”: “My Thoughts”,
“author”: “Alice”,
“views”: 1000,
“body”: “Quarkus is fast.”,
“wordCount”: 3,
“adRevenue”: 50.0
},
{
“id”: 2,
“title”: “Funny Cat”,
“author”: “Bob”,
“views”: 50000,
“videoUrl”: “http://vid.eo/cat”,
“durationSeconds”: 60,
“bitrate”: “4k”,
“fsk”: “12”,
“fileSize”: “3000 MB”,
“adRevenue”: 2500.0
}
]And if you made it all the way down here, you deserve to see me suffering and also writing tests for it. But I’ll spare it here, you need to go and grab it from my Github repository :)
Why This Pattern Works So Well in Quarkus
Quarkus optimizes JSON serialization at build time. When you build a native image, Quarkus already knows which fields each @JsonView needs. No reflection scanning at runtime.
Additional benefits:
No DTO boilerplate
No custom recursion logic
Explicit visibility rules in one place
Fast serialization under load
This scales cleanly across teams and microservices.
What You Can Add Next
Add a WriterInterceptor that selects the correct
@JsonViewautomatically based on JWT roles, eliminating annotations in controllers.Integrate with Keycloak or another IdP and map realm roles to your view hierarchy for secure, multi-tenant APIs.
Combine views with server-side caching so each role-specific serialization path is cached independently.
Add GraphQL endpoints that reuse the same entity and view model for flexible querying without DTOs.d location.
You now have a powerful pattern for shaping API responses without drowning in DTOs. Jackson Views, Panache entities, and REST give you a flexible, fast way to expose exactly the right data to each tenant, role, or feature flag. All while keeping your codebase small and predictable. This pattern scales cleanly across teams and services and fits naturally into modern Quarkus applications.
Now it’s your turn. Fire up quarkus dev, shape the API you’ve always wanted, and go build something great.




Very interesting post. The view vs DTO dialectic is one of the most missunderstood topics by developers and these examples greatly clarify it.
I'd have prefered JSON-B instead of Jackson but I reckon that there are good reasons for chosing the later.