The Problem with MVC Spaghetti
When a Spring Boot project grows, it's tempting to put business logic in controllers or let entities bleed into API responses. This leads to tightly coupled, untestable code. Clean Architecture solves this.
Layer Separation
My project structure follows a strict layering rule:
com.portfolio/
├── controller/ → HTTP layer (receives requests, returns responses)
├── service/ → Business logic (the core of your app)
├── repository/ → Data access (JPA repositories)
├── entity/ → JPA entities (maps to DB tables)
├── dto/
│ ├── request/ → What the API receives (validation here)
│ └── response/ → What the API returns (never expose entities)
└── exception/ → Global error handling
DTOs as API Contracts
Never return JPA entities directly from your controllers. Use DTOs to control exactly what data is exposed:
// Entity — internal, has JPA annotations
@Entity
public class BlogPost {
@Id UUID id;
String title;
String content;
Boolean published;
// ... other fields
}
// Response DTO — external, clean
public record BlogPostResponse(
UUID id,
String title,
String excerpt,
String coverImage,
List<String> tags,
LocalDateTime createdAt
) {}
Global Exception Handling
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(
ResourceNotFoundException ex) {
return ResponseEntity.status(404)
.body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
return ResponseEntity.badRequest()
.body(ApiResponse.error("Validation failed", errors));
}
}
Why This Matters
With this structure, you can swap your database from PostgreSQL to MongoDB without touching a single controller. You can unit test service classes in isolation. And onboarding new developers becomes much easier — everyone knows where everything lives.