Comment structurer un projet Spring Boot professionnel
Tu démarres un nouveau projet Spring Boot et tu te demandes comment l'organiser ? Tu n'es pas seul. La structure d'un projet est souvent négligée au début, mais elle devient critique lorsque le projet grandit.
Une mauvaise structure, c'est comme construire une maison sans plan : ça peut tenir au début, mais dès que tu veux ajouter une pièce, tout devient compliqué. À l'inverse, une bonne structure rend ton code facile à comprendre, à tester et à faire évoluer.
Dans cet article, nous allons voir la structure recommandée pour un projet Spring Boot professionnel, pourquoi elle fonctionne, et comment l'appliquer concrètement.
Contexte technique
Versions utilisées :
- Spring Boot 3.2+
- Java 17+
- Architecture en couches (Controller, Service, Repository)
- Pattern DTO pour séparer entités et objets de transfert
Sommaire
- Pourquoi la structure est importante
- Structure recommandée
- Les packages principaux expliqués
- Architecture en couches : le principe fondamental
- Organisation par couche vs par fonctionnalité
- Exemples concrets
- Pour aller plus loin
Pourquoi la structure est importante
Imagine que tu rejoins une nouvelle équipe. Tu ouvres le projet Spring Boot et tu vois ça :
src/main/java/com/springcraft/app/
├─ Utils.java
├─ Helper.java
├─ Manager.java
├─ UserStuff.java
├─ ProductThing.java
└─ SomeClass.java
Où allez-vous chercher la logique de création d'un utilisateur ? Où se trouve le code qui accède à la base de données ? Impossible à savoir sans ouvrir tous les fichiers.
Maintenant, imaginez cette structure :
src/main/java/com/springcraft/app/
├─ controller/
│ └─ UserController.java
├─ service/
│ └─ UserService.java
├─ repository/
│ └─ UserRepository.java
└─ model/
└─ User.java
En un coup d'œil, vous savez où chercher. C'est ça, une bonne structure.
Les bénéfices concrets
- Onboarding rapide : Un nouveau développeur comprend immédiatement l'organisation
- Maintenance facilitée : Vous retrouvez le code en quelques secondes
- Tests plus simples : Chaque composant est isolé et testable
- Évolution sereine : Ajouter une fonctionnalité ne casse pas l'existant
- Travail en équipe : Moins de conflits Git, chacun travaille sur sa partie
Structure recommandée
Voici la structure que nous recommandons pour un projet Spring Boot professionnel :
springcraft-app/
├─ src/
│ ├─ main/
│ │ ├─ java/
│ │ │ └─ com/springcraft/app/
│ │ │ ├─ controller/ # 🎯 Endpoints REST
│ │ │ │ ├─ UserController.java
│ │ │ │ └─ ProductController.java
│ │ │ ├─ service/ # 💼 Logique métier
│ │ │ │ ├─ UserService.java
│ │ │ │ └─ ProductService.java
│ │ │ ├─ repository/ # 💾 Accès aux données
│ │ │ │ ├─ UserRepository.java
│ │ │ │ └─ ProductRepository.java
│ │ │ ├─ model/ # 📦 Données
│ │ │ │ ├─ entity/
│ │ │ │ │ └─ User.java
│ │ │ │ └─ dto/
│ │ │ │ └─ UserDTO.java
│ │ │ ├─ exception/ # ⚠️ Gestion des erreurs
│ │ │ │ ├─ GlobalExceptionHandler.java
│ │ │ │ └─ ResourceNotFoundException.java
│ │ │ ├─ config/ # ⚙️ Configuration
│ │ │ │ ├─ SecurityConfig.java
│ │ │ │ └─ DatabaseConfig.java
│ │ │ └─ SpringcraftAppApplication.java
│ │ └─ resources/
│ │ ├─ application.yml # Configuration principale
│ │ ├─ application-dev.yml # Configuration développement
│ │ ├─ application-prod.yml # Configuration production
│ │ └─ static/ # Fichiers statiques
│ └─ test/
│ └─ java/
│ └─ com/springcraft/app/
│ ├─ controller/
│ ├─ service/
│ └─ repository/
├─ pom.xml (ou build.gradle)
└─ README.md
Cette structure suit le principe d'organisation par couche technique. Chaque package a une responsabilité claire.
Les packages principaux expliqués
📌 controller/
Le controller est le point d'entrée de votre application. C'est lui qui reçoit les requêtes HTTP (GET, POST, PUT, DELETE) et renvoie les réponses.
Responsabilité : Recevoir une requête, la valider, appeler le service approprié, retourner la réponse.
Ce qu'il NE doit PAS faire : Contenir de la logique métier, accéder directement à la base de données.
Exemple : Un endpoint pour récupérer tous les utilisateurs.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<List<UserDTO>> getAllUsers() {
return ResponseEntity.ok(userService.getAllUsers());
}
}Pourquoi c'est important ? Le controller reste simple et ne fait qu'une chose : gérer les requêtes HTTP. Toute la logique est dans le service.
💼 service/
Le service contient toute la logique métier de votre application. C'est le cerveau.
Responsabilité : Orchestrer les opérations, appliquer les règles métier, gérer les transactions.
Ce qu'il NE doit PAS faire : Gérer les requêtes HTTP directement, connaître les détails de la base de données.
Exemple : Récupérer tous les utilisateurs avec la logique métier.
@Service
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
public UserService(UserRepository userRepository, UserMapper userMapper) {
this.userRepository = userRepository;
this.userMapper = userMapper;
}
public List<UserDTO> getAllUsers() {
// Ici, on pourrait ajouter de la logique métier :
// - Filtrer certains utilisateurs
// - Vérifier des permissions
// - Logger l'accès
// - etc.
return userRepository.findAll()
.stream()
.map(userMapper::toDTO)
.collect(Collectors.toList());
}
}Pourquoi c'est important ? Le service est indépendant de la couche web. Vous pouvez le tester sans démarrer un serveur HTTP. Vous pouvez aussi l'utiliser depuis un job batch, une ligne de commande, etc.
💾 repository/
Le repository gère l'accès aux données (base de données, API externe, fichiers, etc.).
Responsabilité : Sauvegarder, récupérer, mettre à jour, supprimer des données.
Ce qu'il NE doit PAS faire : Contenir de la logique métier.
Exemple : Interface Spring Data JPA.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Spring Data génère automatiquement l'implémentation
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByNameContaining(String name);
}Pourquoi Spring Data ? Spring Data JPA génère automatiquement les requêtes SQL pour vous. Pas besoin d'écrire SELECT * FROM users WHERE email = ? manuellement. Vous définissez juste la méthode avec le bon nom (findByEmail) et Spring fait le reste.
📦 model/
Le package model contient vos données. On le divise souvent en deux sous-packages :
entity/ : Les entités JPA
Les entités représentent vos tables en base de données. Elles sont annotées avec @Entity.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String name;
// Getters, setters, constructeurs...
}dto/ : Les Data Transfer Objects
Les DTOs sont des objets que vous exposez dans votre API. Ils sont différents des entités pour plusieurs raisons :
- Sécurité : Vous ne voulez pas exposer tous les champs (ex: mot de passe hashé)
- Découplage : Changer la structure de la base ne casse pas l'API
- Performance : Vous pouvez n'envoyer que les champs nécessaires
public class UserDTO {
private Long id;
private String email;
private String name;
// Pas de mot de passe, pas de dates internes, etc.
// Seulement ce que le client a besoin de voir
}Pourquoi séparer Entity et DTO ?
Imaginez que vous avez une entité User avec un champ passwordHash. Si vous retournez l'entité directement dans votre API, vous exposez le mot de passe hashé au client. C'est une faille de sécurité.
Avec un DTO, vous contrôlez exactement ce qui sort de votre API.
⚠️ exception/
Ce package gère les erreurs de manière centralisée et professionnelle.
Sans gestion d'exception centralisée, votre API renvoie des erreurs 500 avec des stack traces. Pas très pro.
Avec @ControllerAdvice, vous contrôlez exactement ce qui est renvoyé au client.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
404,
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}Résultat : Au lieu d'une stack trace incompréhensible, le client reçoit une réponse propre :
{
"status": 404,
"message": "Utilisateur non trouvé avec l'ID : 123",
"timestamp": "2026-01-26T10:30:00"
}Nous verrons en détail comment gérer les exceptions dans un article dédié.
⚙️ config/
Le package config contient toutes vos classes de configuration Spring (@Configuration).
Exemples : Configuration de la sécurité, configuration de la base de données, configuration du cache, CORS, etc.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}Architecture en couches : le principe fondamental
L'architecture en couches suit une règle simple : chaque couche ne dépend que de la couche en dessous.
Le flux d'une requête :
- Controller : reçoit la requête HTTP, valide les données, appelle le Service
- Service : exécute la logique métier, appelle le Repository
- Repository : accède à la base de données, renvoie les entités
- Réponse : remonte la chaîne jusqu'au client
Règles importantes
- Le Controller ne parle jamais au Repository directement : Il passe toujours par le Service.
- Le Service ne connaît rien du HTTP : Il ne doit pas manipuler
HttpServletRequestouResponseEntity. - Le Repository ne contient pas de logique métier : Juste des requêtes vers la base.
Pourquoi ces règles ?
- Testabilité : Vous pouvez tester le Service sans démarrer un serveur web
- Réutilisabilité : Le même Service peut être appelé depuis un Controller, un job batch, une commande CLI, etc.
- Maintenance : Si vous changez la façon d'accéder aux données (de MySQL à PostgreSQL), seul le Repository change
Organisation par couche vs par fonctionnalité
Pour un projet avec moins de 10-15 entités, l'organisation par couche technique (celle que nous venons de voir) fonctionne très bien.
Pour un gros projet (> 20 entités), envisagez une organisation par fonctionnalité (ou feature) :
com/springcraft/app/
├─ user/
│ ├─ User.java
│ ├─ UserController.java
│ ├─ UserService.java
│ ├─ UserRepository.java
│ └─ UserDTO.java
├─ product/
│ ├─ Product.java
│ ├─ ProductController.java
│ ├─ ProductService.java
│ └─ ProductRepository.java
└─ order/
├─ Order.java
├─ OrderController.java
└─ OrderService.java
Avantages :
- Tout le code lié à une fonctionnalité est au même endroit
- Plus facile de travailler à plusieurs (moins de conflits Git)
- Simplifie l'extraction vers des microservices si nécessaire
Pour débuter, commencez par l'organisation par couche. Si votre projet grandit, migrez vers l'organisation par fonctionnalité.
Exemples concrets
Exemple 1 : Un endpoint simple
Créer un endpoint pour récupérer tous les utilisateurs.
1. Entity
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String name;
// Constructeurs, getters, setters
}2. DTO
public class UserDTO {
private Long id;
private String email;
private String name;
// Constructeurs, getters, setters
}3. Repository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Spring Data génère automatiquement les méthodes CRUD
}4. Service
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<UserDTO> getAllUsers() {
return userRepository.findAll()
.stream()
.map(user -> new UserDTO(user.getId(), user.getEmail(), user.getName()))
.collect(Collectors.toList());
}
}5. Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<List<UserDTO>> getAllUsers() {
return ResponseEntity.ok(userService.getAllUsers());
}
}Flux de la requête :
- Le client fait
GET /api/users - Le Controller reçoit la requête
- Le Controller appelle
userService.getAllUsers() - Le Service appelle
userRepository.findAll() - Le Repository récupère les données en base
- Le Service convertit les
UserenUserDTO - Le Controller renvoie la réponse au client
Exemple 2 : Validation et gestion d'erreur
Créer un utilisateur avec validation.
DTO avec validation
public class CreateUserRequest {
@NotBlank(message = "L'email est obligatoire")
@Email(message = "Format d'email invalide")
private String email;
@NotBlank(message = "Le nom est obligatoire")
@Size(min = 2, max = 50, message = "Le nom doit contenir entre 2 et 50 caractères")
private String name;
// Getters, setters
}Service avec validation métier
@Service
public class UserService {
private final UserRepository userRepository;
@Transactional
public UserDTO createUser(CreateUserRequest request) {
// Validation métier : l'email doit être unique
if (userRepository.existsByEmail(request.getEmail())) {
throw new ValidationException("Un utilisateur existe déjà avec cet email");
}
User user = new User();
user.setEmail(request.getEmail());
user.setName(request.getName());
User savedUser = userRepository.save(user);
return new UserDTO(savedUser.getId(), savedUser.getEmail(), savedUser.getName());
}
}Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
// @Valid déclenche la validation automatique
UserDTO createdUser = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
}
}Que se passe-t-il en cas d'erreur ?
- Si l'email est vide : Spring renvoie automatiquement une erreur 400 avec le message "L'email est obligatoire"
- Si l'email existe déjà : Le service lance une
ValidationExceptionqui est gérée par le@ControllerAdvice
Pour aller plus loin
Maintenant que vous connaissez la structure de base, voici les prochaines étapes pour approfondir vos connaissances Spring Boot :
📚 Série d'articles complémentaires
- Tests dans Spring Boot : Guide complet - Apprenez à tester votre application avec des tests unitaires et d'intégration
- Configuration multi-environnements avec Spring Boot - Gérez vos profils dev, test et production comme un pro
- Gestion des exceptions dans Spring Boot - Créez des API robustes avec une gestion d'erreur professionnelle
- CRUD complet avec Spring Boot - Construisez une API REST complète de A à Z
- Spring Boot en production : Checklist - Préparez votre application pour la production
🎯 Points clés à retenir
- Organisez par couches : controller, service, repository, model
- Une classe = une responsabilité : Ne mettez pas de logique métier dans le controller
- Utilisez des DTOs : Ne jamais exposer vos entités directement
- Pensez testabilité : Chaque couche doit être testable indépendamment
- Restez cohérent : Toute votre équipe doit suivre la même structure
Une bonne structure ne se voit pas dans le produit final, mais elle fait toute la différence au quotidien. Elle vous permet de développer plus vite, avec moins de bugs, et de dormir tranquille. 😊
Bon développement ! 🚀