🚀 Stop Hardcoding DTOs: Build Smart, Dynamic JSON Responses in Spring Boot

"Can this API return a different response based on the user's role?"

"Sometimes we send full details, sometimes just a summary."

If you've ever heard that during a code review, you know the pain.

Here's how to build flexible yet type-safe dynamic responses in Spring Boot — no hacks, no chaos, just clean design.

💡 The Problem: When One Response Doesn't Fit All

Let's say you have a simple endpoint:

@GetMapping("/user/{id}")
public UserResponse getUser(@PathVariable Long id) {
    return userService.getUser(id);
}

Now product comes along and says:

  • Admins should see full user details
  • Regular users should only see public info
  • Premium users should see some extras

…and suddenly your clean endpoint needs three different response shapes 😩

You could create:

UserBasicResponse, UserPremiumResponse, UserAdminResponse

But that's messy, repetitive, and breaks maintainability.

🚀 The Solution: Dynamic, Context-Aware JSON Responses

Spring Boot + Jackson gives you multiple elegant ways to handle this, depending on your flexibility needs.

We'll explore:

  1. 🧩 Polymorphic Response Classes (@JsonTypeInfo)
  2. 🪄 Conditional Fields with Jackson Views
  3. 🧠 Runtime Response Shaping using Map or JsonNode

🧩 1. Polymorphic Responses with @JsonTypeInfo

Let's define a base response and extend it for specific cases.

🧠 Base Class

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = UserBasicResponse.class, name = "basic"),
    @JsonSubTypes.Type(value = UserPremiumResponse.class, name = "premium"),
    @JsonSubTypes.Type(value = UserAdminResponse.class, name = "admin")
})
public abstract class UserResponse {
    public String type;
}

🧩 Subclasses

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserBasicResponse extends UserResponse {
    private String name;
    private String email;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserPremiumResponse extends UserResponse {
    private String name;
    private String email;
    private String subscription;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserAdminResponse extends UserResponse {
    private String name;
    private String email;
    private String role;
    private List<String> permissions;
}

🧠 Controller

@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id, @RequestParam String role) {
    return switch (role) {
        case "admin" -> new UserAdminResponse("Alice", "[email protected]", "ADMIN", List.of("DELETE", "UPDATE"));
        case "premium" -> new UserPremiumResponse("Bob", "[email protected]", "PREMIUM");
        default -> new UserBasicResponse("Charlie", "[email protected]");
    };
}

✅ Output:

For role=basic

{
  "type": "basic",
  "name": "Charlie",
  "email": "[email protected]"
}

For role=admin

{
  "type": "admin",
  "name": "Alice",
  "email": "[email protected]",
  "role": "ADMIN",
  "permissions": ["DELETE", "UPDATE"]
}

💥 Clean, maintainable, and type-safe.

🪄 2. Conditional Fields Using Jackson Views

If you want to keep a single DTO but expose fields conditionally, use Jackson Views.

Define Views

public class Views {
    public interface Public {}
    public interface Premium extends Public {}
    public interface Admin extends Premium {}
}

Annotate Fields

@Data
public class UserView {
    @JsonView(Views.Public.class)
    private String name;
@JsonView(Views.Public.class)
    private String email;
    @JsonView(Views.Premium.class)
    private String subscription;
    @JsonView(Views.Admin.class)
    private List<String> permissions;
}

Controller with Views

@GetMapping("/view/{id}")
@JsonView(Views.Public.class)
public UserView getUserPublic(@PathVariable Long id) {
    return getMockUser();
}
@GetMapping("/view/premium/{id}")
@JsonView(Views.Premium.class)
public UserView getUserPremium(@PathVariable Long id) {
    return getMockUser();
}
@GetMapping("/view/admin/{id}")
@JsonView(Views.Admin.class)
public UserView getUserAdmin(@PathVariable Long id) {
    return getMockUser();
}
private UserView getMockUser() {
    UserView user = new UserView();
    user.setName("Alice");
    user.setEmail("[email protected]");
    user.setSubscription("Gold");
    user.setPermissions(List.of("DELETE", "UPDATE"));
    return user;
}

✅ Automatically returns only the fields relevant to the view.

🧠 3. Fully Dynamic Responses with Map or JsonNode

For APIs that depend heavily on runtime conditions (e.g. feature flags, plugins, third-party integrations), you can return dynamic maps.

@GetMapping("/dynamic")
public ResponseEntity<Map<String, Object>> getDynamicResponse(@RequestParam String mode) {
    Map<String, Object> response = new LinkedHashMap<>();
    response.put("timestamp", LocalDateTime.now());
    response.put("status", "ok");
if ("compact".equals(mode)) {
        response.put("data", Map.of("message", "Minimal response"));
    } else if ("detailed".equals(mode)) {
        response.put("data", Map.of("user", "Alice", "features", List.of("A", "B", "C")));
    }
    return ResponseEntity.ok(response);
}

Or even with JsonNode for structured flexibility:

@Autowired
private ObjectMapper mapper;
@GetMapping("/jsonnode")
public JsonNode getJsonNodeResponse(@RequestParam String type) {
    ObjectNode node = mapper.createObjectNode();
    node.put("type", type);
    node.put("timestamp", System.currentTimeMillis());
    node.putPOJO("data", Map.of("user", "Bob", "role", type));
    return node;
}

✅ Perfect for:

  • Webhooks
  • Config-driven APIs
  • Multi-tenant backends

⚡ Pro Tip: Wrap Everything in a Unified Response

Even when returning dynamic shapes, always wrap in a consistent structure:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ApiResponse<T> {
    private boolean success;
    private T data;
    private String message;
}

Now you can safely return any dynamic data type:

@GetMapping("/safe")
public ApiResponse<Object> getSafeResponse(@RequestParam String type) {
    if ("admin".equals(type)) {
        return new ApiResponse<>(true, Map.of("role", "ADMIN", "permissions", List.of("READ", "WRITE")), "Admin data");
    }
    return new ApiResponse<>(true, Map.of("user", "Guest"), "Basic data");
}

Consistent ✅ Type-safe ✅ Frontend-friendly ✅

📊 TL;DR

None

💬 Final Thoughts

Dynamic response structures don't have to turn your API into spaghetti. Spring Boot gives you all the tools you need to build context-aware, type-safe, and maintainable dynamic APIs.

The key is balance: flexibility without losing clarity.

So next time someone says:

"We want the same endpoint to return different shapes…"

You just grin and say:

"Handled 😎R.&qut;