Repository / Service / Controller
A common starting point for Spring Boot apps. Each layer has one responsibility and only talks to the layer directly below it:
Repository
A repository is an interface for accessing data from a database. It extends JpaRepository, which gives you a set of CRUD methods for free:
| Method | Description |
|---|---|
findAll() |
Returns all records |
findById(id) |
Returns an Optional by primary key |
save(entity) |
Inserts or updates a record |
deleteById(id) |
Deletes a record by primary key |
existsById(id) |
Returns true if the record exists |
You can also define custom queries just by following JPA's naming convention — no SQL needed. JPA reads the method name and generates the query automatically:
Optional<User> findByFirstname(String firstname);
// → SELECT * FROM users WHERE firstname = ?
List<User> findByAgeGreaterThan(int age);
// → SELECT * FROM users WHERE age > ?
@Repository tells Spring Boot to manage this bean, though it's technically optional since Spring detects any interface extending JpaRepository. It's still good practice to keep it.
The broader principle behind depending on an interface rather than a concrete class — and why it matters — is covered in SOLID — Dependency Inversion.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByFirstname(String firstname);
}
Service
A service holds the business logic. Controllers call it, and it calls the repository. This is where you add validations, transformations, or any processing before reading/writing data.
The repository is injected via constructor injection (preferred over @Autowired on a field) because:
- Dependencies are explicit and required — no hidden state
- Fields can be
final, making the class immutable - Easier to unit test — you can pass a mock directly through the constructor
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User updateUser(User modifiedUser) {
return userRepository.findById(modifiedUser.getId())
.map(existingUser -> {
existingUser.setFirstname(modifiedUser.getFirstname());
// ...
return userRepository.save(existingUser);
})
.orElseThrow(() -> new RuntimeException("User not found"));
}
}
Controller
A controller has no business logic — it only receives HTTP requests and delegates to the right service method.
@RestControlleris a shortcut for@Controller+@ResponseBody, meaning every method returns JSON directly.@RequestMapping("/api/users")sets the base path for all endpoints in the class.- HTTP methods are mapped with
@GetMapping,@PostMapping,@PutMapping,@DeleteMapping, etc. ResponseEntity<T>lets you control the full HTTP response: status code, headers, and body.
Spring Boot automatically handles constructor injection when there is only one constructor, so no @Autowired is needed.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
return userService.getUserById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<User> createUser(@RequestBody User user) {
return new ResponseEntity<>(userService.createUser(user), HttpStatus.CREATED);
}
}
@PathVariablebinds{id}from the URL to the method parameter.@RequestBodydeserializes the JSON request body into a Java object.
This pattern works well for straightforward CRUD apps. As services grow and business logic gets more complex, consider organizing around use cases instead — see Clean Architecture.