QTPortfolio
Clean Architecture in Java: Organizing a Spring Boot Project
JavaSpring BootArchitectureBest Practices

Clean Architecture in Java: Organizing a Spring Boot Project

December 20, 20252 min read~270 words

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.

Enjoyed this article?

Share it with your network or explore more posts below.