Spring Boot applications can grow complex quickly, and how you structure your code is often the difference between a maintainable project and a tangled mess.
Let’s look at a typical Spring Boot application and see how its modules and packages are organized.
Consider this simple web application. It has a few core components:
com.example.myapp
├── Application.java
├── config
│ └── WebConfig.java
├── controller
│ └── UserController.java
├── service
│ └── UserService.java
├── repository
│ └── UserRepository.java
└── domain
└── User.java
Here, Application.java is the main entry point. The config package holds configuration classes like WebConfig. controller contains our web layer (UserController), service has our business logic (UserService), and repository deals with data access (UserRepository). Finally, domain defines our core entities, like the User class.
This structure follows a common layered architecture. The request flows from the controller, through the service, to the repository, and finally to the domain objects.
The Application.java file is often annotated with @SpringBootApplication, which is a convenience annotation that adds @Configuration, @EnableAutoConfiguration, and @ComponentScan at once. This tells Spring to scan for components in the same package and its sub-packages.
package com.example.myapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
The config package, here with WebConfig, is where you’d put custom Spring configurations. For instance, if you needed to configure CORS or set up custom beans, this is the place.
package com.example.myapp.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*");
}
}
The controller package houses the entry points for your web requests. UserController might look like this:
package com.example.myapp.controller;
import com.example.myapp.domain.User;
import com.example.myapp.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
public List<User> getAllUsers() {
return userService.findAll();
}
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) {
return userService.findById(id);
}
@PostMapping
public User createUser(@RequestBody User user) {
return userService.save(user);
}
}
The service layer contains the business logic. UserService orchestrates operations and often delegates to repositories.
package com.example.myapp.service;
import com.example.myapp.domain.User;
import com.example.myapp.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public List<User> findAll() {
return userRepository.findAll();
}
public User findById(Long id) {
return userRepository.findById(id).orElse(null); // In a real app, handle Optional properly
}
public User save(User user) {
return userRepository.save(user);
}
}
The repository layer is responsible for data access. UserRepository would typically extend a Spring Data interface, like JpaRepository.
package com.example.myapp.repository;
import com.example.myapp.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Spring Data JPA provides common CRUD operations automatically
}
And the domain package holds your entities. User is a simple Plain Old Java Object (POJO).
package com.example.myapp.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
// Getters and setters (omitted for brevity)
// Constructors (omitted for brevity)
}
This layered approach is a fundamental pattern. Requests enter at the controller, business logic is handled by the service, and data persistence is managed by the repository. The domain objects are the data structures that move between these layers.
A common alternative or complementary approach is to organize by feature or module. Instead of controller, service, repository at the top level, you might have com.example.myapp.user, com.example.myapp.product, etc., with the layers nested inside each feature package. This can be more scalable for larger applications, as it keeps related code together.
When Spring scans for components using @ComponentScan, it starts from the base package specified (or inferred from the @SpringBootApplication class’s package) and recursively scans all sub-packages. This means any class annotated with @Controller, @Service, @Repository, @Component, etc., within com.example.myapp and its sub-packages will be discovered and registered as Spring beans.
The key takeaway is that Spring’s dependency injection and component scanning work seamlessly with these structured package layouts, making it easy to manage complex applications.
The next logical step is to explore how to handle exceptions gracefully across these layers, especially when dealing with data persistence or external service calls.