Comment structurer un projet Spring Boot professionnel

12 min readPar SpringCraft

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

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

  1. Onboarding rapide : Un nouveau développeur comprend immédiatement l'organisation
  2. Maintenance facilitée : Vous retrouvez le code en quelques secondes
  3. Tests plus simples : Chaque composant est isolé et testable
  4. Évolution sereine : Ajouter une fonctionnalité ne casse pas l'existant
  5. 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 :

  1. Sécurité : Vous ne voulez pas exposer tous les champs (ex: mot de passe hashé)
  2. Découplage : Changer la structure de la base ne casse pas l'API
  3. 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 :

  1. Controller : reçoit la requête HTTP, valide les données, appelle le Service
  2. Service : exécute la logique métier, appelle le Repository
  3. Repository : accède à la base de données, renvoie les entités
  4. Réponse : remonte la chaîne jusqu'au client

Règles importantes

  1. Le Controller ne parle jamais au Repository directement : Il passe toujours par le Service.
  2. Le Service ne connaît rien du HTTP : Il ne doit pas manipuler HttpServletRequest ou ResponseEntity.
  3. 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 :

  1. Le client fait GET /api/users
  2. Le Controller reçoit la requête
  3. Le Controller appelle userService.getAllUsers()
  4. Le Service appelle userRepository.findAll()
  5. Le Repository récupère les données en base
  6. Le Service convertit les User en UserDTO
  7. 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 ValidationException qui 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

  1. Tests dans Spring Boot : Guide complet - Apprenez à tester votre application avec des tests unitaires et d'intégration
  2. Configuration multi-environnements avec Spring Boot - Gérez vos profils dev, test et production comme un pro
  3. Gestion des exceptions dans Spring Boot - Créez des API robustes avec une gestion d'erreur professionnelle
  4. CRUD complet avec Spring Boot - Construisez une API REST complète de A à Z
  5. Spring Boot en production : Checklist - Préparez votre application pour la production

🎯 Points clés à retenir

  1. Organisez par couches : controller, service, repository, model
  2. Une classe = une responsabilité : Ne mettez pas de logique métier dans le controller
  3. Utilisez des DTOs : Ne jamais exposer vos entités directement
  4. Pensez testabilité : Chaque couche doit être testable indépendamment
  5. 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 ! 🚀