1. Correct Design of Controller and Service
Principle: Thin Controller, Fat Service
Controllers should only handle HTTP concerns: routing, request/response formatting, and returning status codes.
Services should encapsulate business logic, including all validations and error throwing.
Why This Matters
1. Separation of Concerns
Layer | Responsibility |
---|---|
Controller | Handle HTTP interface |
Service | Contain domain logic and validation |
Exception Handler | Centralize error-to-response mapping |
2. Centralized Error Handling
Services throw exceptions like
DuplicateException(AuthErrorCode.USER_EMAIL_DUPLICATE)
Global exception handler converts that into a uniform API error response
3. Thin Controllers Are Easier to Maintain
// ✅ Recommended
@PostMapping("register")
public ResponseEntity<MemberDTO> register(@RequestBody MemberDTO dto) {
Member member = service.register(dto);
return ResponseEntity.ok(dto);
}
// ❌ Avoid
@PostMapping("register")
public ResponseEntity<?> register(@RequestBody MemberDTO dto) {
try {
service.register(dto);
return ResponseEntity.ok(dto);
} catch (Exception e) {
return ResponseEntity.status(500).body("Error");
}
}
2. Sequence to Create Custom Exception Handling
Step-by-Step Workflow
Step 1: Define AuthErrorCode
Enum
public enum AuthErrorCode {
USER_EMAIL_DUPLICATE("User already exists", HttpStatus.CONFLICT),
USER_NOT_FOUND("User not found", HttpStatus.NOT_FOUND),
// ... other error codes
;
private final String message;
private final HttpStatus httpStatus;
AuthErrorCode(String message, HttpStatus status) {
this.message = message;
this.httpStatus = status;
}
public String getMessage() { return message; }
public HttpStatus getHttpStatus() { return httpStatus; }
}
Step 2: Create Custom Exception
public class DuplicateException extends RuntimeException {
private final AuthErrorCode errorCode;
public DuplicateException(AuthErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public AuthErrorCode getErrorCode() {
return errorCode;
}
}
Step 3: Update Service Layer to Throw Exception
public Member registerMember(Member member) {
if (repository.findByEmail(member.getEmail()).isPresent()) {
throw new DuplicateException(AuthErrorCode.USER_EMAIL_DUPLICATE);
}
return repository.save(member);
}
Step 4: Refactor Controller to Handle Only Success
@PostMapping("registerMember")
public ResponseEntity<MemberDTO> registerMember(@RequestBody MemberDTO memberDTO) {
Member member = mapper.toEntity(memberDTO);
Member registered = memberService.registerMember(member);
memberDTO.setId(registered.getId());
return ResponseEntity.ok(memberDTO);
}
Step 5: Global Exception Handler
@ExceptionHandler(DuplicateException.class)
public ResponseEntity<RestAPIResponse> handleDuplicate(DuplicateException ex) {
return ResponseEntity
.status(ex.getErrorCode().getHttpStatus())
.body(new RestAPIResponse(
ex.getErrorCode().getMessage(),
ex.getErrorCode().name(),
ex.getErrorCode().getHttpStatus().value(),
System.currentTimeMillis()
));
}
3. Error Handling Between Microservices
Strategy for Service-to-Service Communication
Goal: Ensure service calls (e.g., auth-service
→ member-service
) result in structured, traceable, and meaningful error handling.
Best Practices
Always use
ResponseEntity<RestAPIResponse>
even for simple internal endpoints (no rawboolean
returns).Throw domain-specific exceptions in each service and convert them into consistent error responses using global handler.
Use
AuthErrorCode
or a sharedErrorCode
library across services to standardize error definitions.
Example
In member-service (token deletion)
@PostMapping("deleteAllToken")
public ResponseEntity<RestAPIResponse> deleteAllToken(@RequestBody String token) {
boolean deleted = memberService.deleteAllToken(token);
if (!deleted) {
throw new TokenDeletionException(AuthErrorCode.TOKEN_DELETION_FAILED);
}
return ResponseEntity.ok(RestAPIResponse.success("All tokens deleted successfully"));
}
In auth-service
public void logout(HttpServletRequest request) {
ResponseEntity<RestAPIResponse> response = memberClient.deleteAllToken(token);
if (response.getStatusCode() != HttpStatus.OK) {
throw new TokenDeletionException(AuthErrorCode.TOKEN_DELETION_FAILED);
}
// proceed with logout
}
Benefits
Error messages are consistent across services
Easier to debug and trace failures
Structured JSON responses for monitoring and logging
Seamless integration with Swagger/OpenAPI and frontend error handling
4. Things I Did Care Using Global Exception Handler
When to Use Global Exception Handler:
Scenario | Example |
---|---|
Most application errors | Validation failures, not found, conflicts, etc. |
Business rule exceptions | Duplicate user, invalid credentials, token expired |
Unexpected server errors | DB timeout, IO error, downstream service failure |
RESTful responses | All API-facing logic should go through it |
✔️ These should always be handled centrally via @ControllerAdvice
to ensure consistency and maintainability.
5. The Value of Structured Error Architecture
While it may seem like extra effort to create custom exceptions and enum-based error codes for cases as simple as “Failed to register member,” what you’re actually building is a resilient, maintainable, and enterprise-grade error handling system.
Here’s what you’re gaining:
1. Strong Typing
You’re not just passing a string — you’re throwing a typed, predictable error object. This makes testing and exception handling logic more reliable and maintainable.
2. Consistent HTTP Semantics
Every AuthErrorCode
carries an appropriate HTTP status code. You no longer need to manually assign status codes in each controller or exception.
3. Centralized Vocabulary of Errors
Your AuthErrorCode
enum becomes a living catalog of all failure modes in the system. This is great for documentation, onboarding, and Swagger/OpenAPI support.
4. Future i18n/Localization Readiness
You can easily map AuthErrorCode
names to translated messages in frontend or external error files.
5. Better Logging, Monitoring, and Debugging
Logs and structured JSON error responses now include error codes, timestamps, and context — making it easier to debug issues across distributed systems.
6. Testability
You can now write assertions like:
assertEquals(AuthErrorCode.TOKEN_SAVE_FAILED, ex.getErrorCode());
Rather than relying on fragile message string matching.
✅ In summary: this architecture doesn’t just help you return better errors — it builds the foundation for reliable, scalable, and observable service-to-service communication across your REST API ecosystem.