Architecture Multi-Tiers
Quand tout le code est dans main(), le projet devient impossible à maintenir dès qu'il grossit. L'architecture multi-tiers découpe l'application en couches ayant chacune une responsabilité unique.
Notions théoriques
Le problème du code spaghetti
public class Main {
public static void main(String[] args) {
// Tout dans main : affichage, logique métier, accès données... 300 lignes
Scanner sc = new Scanner(System.in);
System.out.println("Nom du personnage ?");
String nom = sc.nextLine();
Connection conn = DriverManager.getConnection("jdbc:mysql://...");
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM personnages WHERE nom=?");
stmt.setString(1, nom);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
int force = rs.getInt("force");
if (force > 100) {
System.out.println(nom + " est puissant !");
}
}
// ... 200 lignes de plus
}
}
Ce code est impossible à tester, difficile à faire évoluer et incompréhensible pour un autre développeur.
Les 4 couches
┌─────────────────────────────────┐
│ Présentation │ main(), affichage console, contrôleurs web
├─────────────────────────────────┤
│ Service │ logique métier (règles du jeu)
├─────────────────────────────────┤
│ Repository │ accès aux données (BDD, fichiers, API)
├─────────────────────────────────┤
│ Domaine │ entités pures (Personnage, Guerrier...)
└───────────────────────────── ────┘
Couche Domaine : les entités métier pures. Elles ne dépendent de rien d'autre.
// package domaine
public class Guerrier extends Personnage {
public int attaquer() { return this.force; }
}
Couche Repository : accès aux données. Isole la technique (JDBC, fichier) du reste.
// package repository
public interface PersonnageRepository {
Optional<Personnage> findById(Long id);
List<Personnage> findAll();
void save(Personnage p);
}
public class PersonnageRepositoryMemoire implements PersonnageRepository {
private Map<Long, Personnage> stockage = new HashMap<>();
// implémentation en mémoire pour commencer
}
Couche Service : la logique métier. Orchestre les entités et les repositories.
// package service
public class PersonnageService {
private final PersonnageRepository repository;
// Injection de dépendance via constructeur
public PersonnageService(PersonnageRepository repository) {
this.repository = repository;
}
public Optional<Personnage> trouver(Long id) {
return repository.findById(id);
}
public Personnage creer(String nom, TypePersonnage type) {
Personnage p = PersonnageFactory.creer(type, nom);
repository.save(p);
return p;
}
}
Couche Présentation : le point d'entrée. Elle ne contient aucune logique.
// package presentation (ou Main.java)
public class Main {
public static void main(String[] args) {
// Composition des dépendances (à la main, Spring le fera automatiquement)
PersonnageRepository repo = new PersonnageRepositoryMemoire();
PersonnageService service = new PersonnageService(repo);
// Utilisation
Personnage aragorn = service.creer("Aragorn", TypePersonnage.GUERRIER);
System.out.println("Créé : " + aragorn.getNom());
}
}
Règle de dépendance
Une couche haute peut dépendre d'une couche basse, jamais l'inverse. Le domaine ne connaît pas les repositories. Les repositories ne connaissent pas les services.
Présentation → Service → Repository → Domaine
Si le Repository connaissait le Service, on aurait une dépendance circulaire : impossible à tester, impossible à faire évoluer.
DTO — Data Transfer Object
Un DTO est un objet simple qui transporte des données entre couches, sans logique métier. Il protège les entités de domaine d'être exposées directement.
// Entité : dans le domaine, peut avoir des méthodes complexes
public class Personnage { ... }
// DTO : simple objet de données pour l'affichage
public class PersonnageDTO {
private String nom;
private int force;
private String type;
// Getters seulement, construit à partir de l'entité
public static PersonnageDTO depuis(Personnage p) {
PersonnageDTO dto = new PersonnageDTO();
dto.nom = p.getNom();
dto.force = p.getForce();
dto.type = p.getClass().getSimpleName();
return dto;
}
}
Injection de dépendance
L'injection de dépendance consiste à passer les dépendances via le constructeur plutôt que de les instancier en interne. Cela permet de substituer facilement les implémentations (test, production).
// MAL : dépendance instanciée en interne (impossible à tester)
public class PersonnageService {
private PersonnageRepository repo = new PersonnageRepositoryJDBC(); // verrouillé
}
// BIEN : injection via constructeur
public class PersonnageService {
private final PersonnageRepository repo;
public PersonnageService(PersonnageRepository repo) {
this.repo = repo; // peut être une implémentation mémoire en test
}
}
Spring Boot automatise l'injection de dépendance avec @Autowired, @Service, @Repository. Ce que vous faites ici "à la main" est exactement ce que Spring fait automatiquement. Comprendre la version manuelle rend Spring beaucoup plus compréhensible.
Organisation des packages
src/main/java/
└── com/joliciel/rpg/
├── domaine/
│ ├── Personnage.java
│ ├── Guerrier.java
│ └── Mage.java
├── repository/
│ ├── PersonnageRepository.java (interface)
│ └── PersonnageRepositoryMemoire.java
├── service/
│ └── PersonnageService.java
├── dto/
│ └── PersonnageDTO.java
└── Main.java
Exemple pratique
// domaine/Personnage.java
public abstract class Personnage {
protected String nom;
protected int force;
protected int defense;
public abstract int attaquer();
// getters...
}
// repository/PersonnageRepository.java
public interface PersonnageRepository {
Optional<Personnage> findById(Long id);
List<Personnage> findAll();
Personnage save(Personnage p);
void deleteById(Long id);
}
// repository/PersonnageRepositoryMemoire.java
public class PersonnageRepositoryMemoire implements PersonnageRepository {
private final Map<Long, Personnage> stockage = new HashMap<>();
private long compteurId = 1L;
@Override
public Optional<Personnage> findById(Long id) {
return Optional.ofNullable(stockage.get(id));
}
@Override
public List<Personnage> findAll() {
return new ArrayList<>(stockage.values());
}
@Override
public Personnage save(Personnage p) {
stockage.put(compteurId++, p);
return p;
}
@Override
public void deleteById(Long id) {
stockage.remove(id);
}
}
// service/PersonnageService.java
public class PersonnageService {
private final PersonnageRepository repository;
public PersonnageService(PersonnageRepository repository) {
this.repository = repository;
}
public Personnage creer(String nom, TypePersonnage type) {
if (nom == null || nom.isBlank()) {
throw new IllegalArgumentException("Le nom ne peut pas être vide");
}
Personnage p = PersonnageFactory.creer(type, nom);
return repository.save(p);
}
public Optional<Personnage> trouver(Long id) {
return repository.findById(id);
}
public List<Personnage> tousLesPersonnages() {
return repository.findAll();
}
}
// Main.java
public class Main {
public static void main(String[] args) {
// Composition manuelle des couches
PersonnageRepository repo = new PersonnageRepositoryMemoire();
PersonnageService service = new PersonnageService(repo);
// Création
service.creer("Aragorn", TypePersonnage.GUERRIER);
service.creer("Gandalf", TypePersonnage.MAGE);
// Lecture
service.tousLesPersonnages().forEach(p ->
System.out.println(p.getNom() + " (force=" + p.getForce() + ")"));
}
}
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Vous allez refactorer le projet RPG en 4 packages distincts correspondant aux 4 couches.
Étape 1 — Créer l'interface PersonnageRepository
Créez l'interface PersonnageRepository dans le package repository avec les méthodes de base.
PersonnageService reçoit PersonnageRepository (l'interface) et non PersonnageRepositoryMemoire (l'implémentation). Ce principe — "programmer vers une interface" — permet de changer l'implémentation (mémoire → JDBC → Spring Data) sans toucher au service.
Étape 2 — Implémenter PersonnageService avec injection
Créez PersonnageService avec le repository injecté via constructeur, et une méthode creer().
Vérifier que le nom n'est pas vide est une règle métier → elle appartient au Service. La couche Présentation ne doit pas contenir de règles métier. Si demain vous ajoutez une interface web, la validation s'appliquera automatiquement.
Étape 3 — Composer les couches dans le main
Dans Main.java, assemblez les couches manuellement et utilisez le service.
Le main est le seul endroit où les couches sont assemblées. Aucune couche ne crée ses propres dépendances. Cette "composition à la racine" (Root Composition) est le fondement de l'injection de dépendances. Spring Boot le fait automatiquement avec le conteneur IoC.