Spring BootSpring Boot Project Structure
Spring Boot

Spring Boot Project Structure

A well-organized Spring Boot project structure makes code easy to navigate, test, and maintain. The standard layered package structure — controller, service, repository, model — separates concerns clearly and is understood by every Java developer. Here's the complete structure, every file explained, and the conventions that make large Spring Boot codebases maintainable.

The Complete Project Structure

A production Spring Boot project follows a consistent directory layout. The src/main/java directory holds application code organized by layer. src/main/resources holds configuration and static assets. src/test mirrors the main structure for tests.
Java
// Complete Spring Boot project structure:
//
// myapp/
// ├── src/
// │   ├── main/
// │   │   ├── java/
// │   │   │   └── com/example/myapp/
// │   │   │       ├── MyappApplication.java        ← entry point
// │   │   │       ├── config/                      ← @Configuration classes
// │   │   │       │   ├── SecurityConfig.java
// │   │   │       │   ├── WebMvcConfig.java
// │   │   │       │   └── CacheConfig.java
// │   │   │       ├── controller/                  ← @RestController classes
// │   │   │       │   ├── UserController.java
// │   │   │       │   ├── OrderController.java
// │   │   │       │   └── ProductController.java
// │   │   │       ├── service/                     ← @Service classes
// │   │   │       │   ├── UserService.java
// │   │   │       │   ├── OrderService.java
// │   │   │       │   └── ProductService.java
// │   │   │       ├── repository/                  ← @Repository interfaces
// │   │   │       │   ├── UserRepository.java
// │   │   │       │   ├── OrderRepository.java
// │   │   │       │   └── ProductRepository.java
// │   │   │       ├── model/                       ← @Entity classes
// │   │   │       │   ├── User.java
// │   │   │       │   ├── Order.java
// │   │   │       │   └── Product.java
// │   │   │       ├── dto/                         ← Request/Response objects
// │   │   │       │   ├── request/
// │   │   │       │   │   ├── CreateUserRequest.java
// │   │   │       │   │   └── CreateOrderRequest.java
// │   │   │       │   └── response/
// │   │   │       │       ├── UserResponse.java
// │   │   │       │       └── OrderResponse.java
// │   │   │       ├── exception/                   ← Custom exceptions + handler
// │   │   │       │   ├── UserNotFoundException.java
// │   │   │       │   ├── DuplicateEmailException.java
// │   │   │       │   └── GlobalExceptionHandler.java
// │   │   │       └── util/                        ← Utility/helper classes
// │   │   │           ├── DateUtils.java
// │   │   │           └── StringUtils.java
// │   │   └── resources/
// │   │       ├── application.properties           ← main configuration
// │   │       ├── application-dev.properties       ← dev overrides
// │   │       ├── application-prod.properties      ← prod settings
// │   │       ├── static/                          ← CSS, JS, images
// │   │       ├── templates/                       ← Thymeleaf templates
// │   │       └── db/migration/                    ← Flyway SQL migrations
// │   │           ├── V1__create_users_table.sql
// │   │           └── V2__create_orders_table.sql
// │   └── test/
// │       └── java/
// │           └── com/example/myapp/
// │               ├── MyappApplicationTests.java   ← context load test
// │               ├── controller/
// │               │   └── UserControllerTest.java  ← @WebMvcTest
// │               ├── service/
// │               │   └── UserServiceTest.java     ← unit tests
// │               └── repository/
// │                   └── UserRepositoryTest.java  ← @DataJpaTest
// ├── .gitignore
// ├── mvnw / mvnw.cmd                              ← Maven wrapper
// └── pom.xml

The Entry Point — MyappApplication.java

The application class with @SpringBootApplication must be in the root package — above all other packages. This ensures @ComponentScan picks up every class in the application.
Java
// CORRECT — in the root package:
// com/example/myapp/MyappApplication.java
package com.example.myapp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
// Equivalent to: @Configuration + @EnableAutoConfiguration + @ComponentScan
// @ComponentScan scans com.example.myapp and ALL sub-packages:
// com.example.myapp.controller — found
// com.example.myapp.service   — found
// com.example.myapp.repository — found
public class MyappApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyappApplication.class, args);
    }
}

// WRONG — placed inside a sub-package:
// com/example/myapp/controller/MyappApplication.java
// @ComponentScan would only scan com.example.myapp.controller
// Service, repository, config packages would be MISSED

// Customize component scan if needed:
@SpringBootApplication(scanBasePackages = {
    "com.example.myapp",
    "com.example.shared"   // scan an external shared module too
})
public class MyappApplication { }

The config Package

The config package holds all @Configuration classes — beans that require programmatic configuration rather than annotation-based auto-detection. Security setup, CORS configuration, bean overrides, and infrastructure configuration all live here.
Java
// config/SecurityConfig.java:
package com.example.myapp.config;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final UserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

// config/WebMvcConfig.java:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://myapp.com", "http://localhost:3000")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new RequestLoggingInterceptor())
            .addPathPatterns("/api/**");
    }
}

// config/CacheConfig.java:
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer())
            );
        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
}

The controller Package

Controllers handle HTTP requests. They validate input, delegate to services, and return responses. No business logic, no direct repository access — controllers only translate between HTTP and the service layer.
Java
// controller/UserController.java:
package com.example.myapp.controller;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {

    private final UserService userService;

    @GetMapping
    public Page<UserResponse> getUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt") String sortBy) {
        return userService.findAll(PageRequest.of(page, size, Sort.by(sortBy).descending()));
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse createUser(@RequestBody @Valid CreateUserRequest request) {
        log.info("Creating user with email: {}", request.email());
        return userService.createUser(request);
    }

    @PutMapping("/{id}")
    public UserResponse updateUser(
            @PathVariable Long id,
            @RequestBody @Valid UpdateUserRequest request) {
        return userService.updateUser(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
    }

    @GetMapping("/{id}/orders")
    public List<OrderResponse> getUserOrders(@PathVariable Long id) {
        return userService.getOrdersForUser(id);
    }
}

The service Package

Services contain all business logic. They're the core of the application — orchestrating repositories, enforcing business rules, handling transactions, and coordinating between multiple components.
Java
// service/UserService.java:
package com.example.myapp.service;

@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class UserService {

    private final UserRepository userRepository;
    private final OrderRepository orderRepository;
    private final EmailService emailService;
    private final PasswordEncoder passwordEncoder;

    @Transactional(readOnly = true)
    public Page<UserResponse> findAll(Pageable pageable) {
        return userRepository.findAll(pageable).map(UserResponse::from);
    }

    @Transactional(readOnly = true)
    public Optional<UserResponse> findById(Long id) {
        return userRepository.findById(id).map(UserResponse::from);
    }

    public UserResponse createUser(CreateUserRequest request) {
        // Business rule — email must be unique:
        if (userRepository.existsByEmail(request.email())) {
            throw new DuplicateEmailException("Email already in use: " + request.email());
        }

        User user = User.builder()
            .email(request.email())
            .name(request.name())
            .password(passwordEncoder.encode(request.password()))
            .active(true)
            .createdAt(LocalDateTime.now())
            .build();

        User saved = userRepository.save(user);
        emailService.sendWelcome(saved.getEmail(), saved.getName());
        log.info("User created: id={}, email={}", saved.getId(), saved.getEmail());

        return UserResponse.from(saved);
    }

    public UserResponse updateUser(Long id, UpdateUserRequest request) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));

        user.setName(request.name());
        if (request.password() != null) {
            user.setPassword(passwordEncoder.encode(request.password()));
        }

        return UserResponse.from(userRepository.save(user));
    }

    public void deleteUser(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        userRepository.delete(user);
    }

    @Transactional(readOnly = true)
    public List<OrderResponse> getOrdersForUser(Long userId) {
        if (!userRepository.existsById(userId)) {
            throw new UserNotFoundException(userId);
        }
        return orderRepository.findByUserId(userId).stream()
            .map(OrderResponse::from)
            .toList();
    }
}

The repository Package

Repositories are the data access layer. In Spring Data JPA, they're interfaces — Spring generates the implementation automatically. Custom queries, pagination, and projections all live here.
Java
// repository/UserRepository.java:
package com.example.myapp.repository;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // Spring Data derives SQL from method names:
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    List<User> findByActiveTrue();
    List<User> findByNameContainingIgnoreCase(String name);

    // Pagination:
    Page<User> findByActiveTrue(Pageable pageable);

    // Custom JPQL query:
    @Query("SELECT u FROM User u WHERE u.createdAt >= :since AND u.active = true")
    List<User> findActiveUsersSince(@Param("since") LocalDateTime since);

    // Native SQL query:
    @Query(
        value = "SELECT * FROM users WHERE last_login < :cutoff ORDER BY last_login",
        nativeQuery = true
    )
    List<User> findInactiveUsers(@Param("cutoff") LocalDateTime cutoff);

    // DTO projection — fetch only specific columns (more efficient):
    @Query("SELECT new com.example.myapp.dto.response.UserSummary(u.id, u.name, u.email) FROM User u")
    List<UserSummary> findAllSummaries();

    // Modifying query — update without loading entities:
    @Modifying
    @Query("UPDATE User u SET u.active = false WHERE u.id = :id")
    int deactivateUser(@Param("id") Long id);

    // Count query:
    long countByActiveTrue();
}

The model Package

The model package contains JPA entity classes — Java objects that map directly to database tables. Entities are only used within the backend; they are never returned directly from controllers.
Java
// model/User.java:
package com.example.myapp.model;

@Entity
@Table(name = "users", indexes = {
    @Index(name = "idx_users_email", columnList = "email"),
    @Index(name = "idx_users_active", columnList = "active")
})
@EntityListeners(AuditingEntityListener.class)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 255)
    private String email;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false)
    @JsonIgnore   // never serialize the password
    private String password;

    @Column(nullable = false)
    @Builder.Default
    private boolean active = true;

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    // Relationship — one user has many orders:
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JsonIgnore   // avoid infinite recursion
    private List<Order> orders = new ArrayList<>();

    // Relationship — many users have many roles:
    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
        name = "user_roles",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private Set<Role> roles = new HashSet<>();
}

The dto Package

DTOs (Data Transfer Objects) define what goes in and what comes out of the API. Request DTOs carry validation annotations. Response DTOs expose only safe, relevant fields — never the entity directly.
Java
// dto/request/CreateUserRequest.java:
package com.example.myapp.dto.request;

public record CreateUserRequest(

    @NotBlank(message = "Email is required")
    @Email(message = "Must be a valid email address")
    String email,

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    String name,

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    String password

) { }

// dto/request/UpdateUserRequest.java:
public record UpdateUserRequest(

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    String name,

    @Size(min = 8, message = "Password must be at least 8 characters")
    String password   // nullable — only update if provided

) { }

// dto/response/UserResponse.java:
package com.example.myapp.dto.response;

public record UserResponse(
    Long id,
    String email,
    String name,
    boolean active,
    LocalDateTime createdAt
) {
    // Factory method converts Entity → DTO:
    public static UserResponse from(User user) {
        return new UserResponse(
            user.getId(),
            user.getEmail(),
            user.getName(),
            user.isActive(),
            user.getCreatedAt()
        );
    }
}

// dto/response/UserSummary.java — lightweight projection:
public record UserSummary(Long id, String name, String email) { }

The exception Package

The exception package contains custom exception classes and the global exception handler. Centralized exception handling means no try-catch blocks in controllers — exceptions propagate up and are caught in one place.
Java
// exception/UserNotFoundException.java:
package com.example.myapp.exception;

public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(Long id) {
        super("User not found with id: " + id);
    }
    public UserNotFoundException(String email) {
        super("User not found with email: " + email);
    }
}

// exception/DuplicateEmailException.java:
public class DuplicateEmailException extends RuntimeException {
    public DuplicateEmailException(String message) {
        super(message);
    }
}

// exception/GlobalExceptionHandler.java:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleUserNotFound(UserNotFoundException ex) {
        return new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
    }

    @ExceptionHandler(DuplicateEmailException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorResponse handleDuplicateEmail(DuplicateEmailException ex) {
        return new ErrorResponse("DUPLICATE_EMAIL", ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
        String errors = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.joining(", "));
        return new ErrorResponse("VALIDATION_FAILED", errors);
    }

    @ExceptionHandler(AccessDeniedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public ErrorResponse handleAccessDenied(AccessDeniedException ex) {
        return new ErrorResponse("ACCESS_DENIED", "You don't have permission to access this resource");
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGeneral(Exception ex) {
        log.error("Unexpected error", ex);
        return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred");
    }
}

// Shared error response record:
public record ErrorResponse(String code, String message) { }

The resources Directory

The resources directory holds all non-Java files — configuration, static assets, templates, and database migration scripts. Understanding each subdirectory prevents common misconfiguration.
Java
// src/main/resources/ layout:
//
// application.properties          ← primary configuration (all profiles)
// application-dev.properties      ← development overrides
// application-prod.properties     ← production settings (use env vars for secrets)
// application-test.properties     ← test-specific settings
//
// static/                         ← served directly at the root URL
//   css/style.css                 → http://localhost:8080/css/style.css
//   js/app.js                     → http://localhost:8080/js/app.js
//   images/logo.png               → http://localhost:8080/images/logo.png
//   favicon.ico                   → http://localhost:8080/favicon.ico
//
// templates/                      ← Thymeleaf/Freemarker server-side templates
//   index.html
//   users/list.html
//   users/detail.html
//
// db/migration/                   ← Flyway migration scripts (versioned)
//   V1__create_schema.sql         ← naming: V{version}__{description}.sql
//   V2__create_users_table.sql
//   V3__add_user_roles.sql
//   V4__create_orders_table.sql
//   R__refresh_views.sql          ← R prefix = repeatable migration

// application.properties — typical full configuration:
server.port=8080
server.servlet.context-path=/

spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=root
spring.datasource.password=${DB_PASSWORD}
spring.datasource.hikari.maximum-pool-size=10

spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false

spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration

spring.data.redis.host=localhost
spring.data.redis.port=6379

logging.level.root=INFO
logging.level.com.example=DEBUG

management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=when-authorized

The test Directory Structure

The test directory mirrors the main directory structure exactly. Each class in main has a corresponding test class in the same sub-package. Different test slice annotations load only the layers needed.
Java
// src/test/java/com/example/myapp/

// 1. Context load test — verifies the entire application starts:
@SpringBootTest
class MyappApplicationTests {
    @Test
    void contextLoads() { }
}

// 2. Controller slice test — loads only MVC layer:
// src/test/java/com/example/myapp/controller/UserControllerTest.java
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getUser_returnsUser() throws Exception {
        when(userService.findById(1L))
            .thenReturn(Optional.of(new UserResponse(1L, "alice@test.com", "Alice", true, LocalDateTime.now())));

        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.email").value("alice@test.com"));
    }
}

// 3. Service unit test — no Spring context needed:
// src/test/java/com/example/myapp/service/UserServiceTest.java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock private UserRepository userRepository;
    @Mock private EmailService emailService;
    @Mock private PasswordEncoder passwordEncoder;
    @InjectMocks private UserService userService;

    @Test
    void createUser_withDuplicateEmail_throwsException() {
        when(userRepository.existsByEmail("alice@test.com")).thenReturn(true);

        assertThatThrownBy(() ->
            userService.createUser(new CreateUserRequest("alice@test.com", "Alice", "pass1234")))
            .isInstanceOf(DuplicateEmailException.class);
    }
}

// 4. Repository slice test — loads only JPA with H2:
// src/test/java/com/example/myapp/repository/UserRepositoryTest.java
@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void findByEmail_returnsUser() {
        User saved = userRepository.save(
            User.builder().email("alice@test.com").name("Alice").password("hashed").build());

        Optional<User> found = userRepository.findByEmail("alice@test.com");

        assertThat(found).isPresent();
        assertThat(found.get().getId()).isEqualTo(saved.getId());
    }
}