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() + ")"));
}
}