3.5 Designing a Global Common Response Structure
1. Why Do We Need a Common Response Structure?
As you build more APIs, each controller can end up returning data in wildly different shapes.
// Controller A response
{ "id": 1, "name": "John" }
// Controller B response (on error)
{ "message": "User not found." }
// Controller C response (list query)
[{ "id": 1 }, { "id": 2 }]
When response formats are inconsistent, frontend developers must interpret a different structure for every endpoint, and error-handling logic must be written differently for each case — making collaboration unnecessarily difficult.
By introducing a Common Response Wrapper, all API responses are delivered inside a ** consistent envelope**, regardless of the endpoint.
2. Designing the Common Response Class
public class ApiResponse<T> {
private boolean success; // Whether the request succeeded
private String message; // Human-readable result message
private T data; // Actual response data (generic)
// Private constructor — only accessible via static factory methods
private ApiResponse(boolean success, String message, T data) {
this.success = success;
this.message = message;
this.data = data;
}
// ✅ Factory method for successful responses
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "Request processed successfully.", data);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(true, message, data);
}
// ✅ Factory method for failed responses
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(false, message, null);
}
// Getters omitted (use Lombok @Getter or write manually)
}
3. Applying to Controllers
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<UserResponseDto>> getUser(@PathVariable Long id) {
UserResponseDto user = userService.findById(id);
if (user == null) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.fail("No user found with the given ID."));
}
return ResponseEntity.ok(ApiResponse.success(user));
}
@PostMapping
public ResponseEntity<ApiResponse<UserResponseDto>> createUser(
@RequestBody UserCreateRequestDto request) {
UserResponseDto created = userService.create(request);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ApiResponse.success("User created successfully.", created));
}
}
4. Actual JSON Response Examples
On success:
{
"success": true,
"message": "Request processed successfully.",
"data": {
"id": 1,
"name": "John",
"totalPoints": 1000
}
}
On failure:
{
"success": false,
"message": "No user found with the given ID.",
"data": null
}
5. Production-Ready Version with Lombok
In real projects, Lombok annotations like @Getter, @Builder, and @AllArgsConstructor keep the code much more concise.
import lombok.Getter;
@Getter
public class ApiResponse<T> {
private final boolean success;
private final String message;
private final T data;
private ApiResponse(boolean success, String message, T data) {
this.success = success;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "OK", data);
}
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(false, message, null);
}
}
Key Takeaway: A common response class ensures your entire team builds to a consistent API contract. The frontend always knows to look for
success,message, anddata— three fields, no surprises. This pattern is a foundational building block in every production-grade Spring Boot project.