Aller au contenu principal

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

info

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
}
}
info

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


Quelle couche contient les entités métier pures (Personnage, Guerrier) ?


Quelle couche est responsable de l'accès aux données (base de données, fichiers) ?


Quelle est la règle de dépendance entre les couches ?


Pourquoi injecte-t-on les dépendances via le constructeur plutôt que de les instancier en interne ?


À quoi sert un DTO (Data Transfer Object) ?


Quel framework Java automatise l'injection de dépendance avec @Service et @Repository ?


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.


Bonne pratique - Dépendre de l'interface, pas de l'implémentation

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().


Bonne pratique - La validation appartient au Service, pas à la Présentation

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.


Bonne pratique - La composition dans le main, pas dans les couches

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.

📌 Une solution