Imaginez travailler dans une banque depuis 20 ans, certains problèmes reviennent régulièrement : comment gérer l’accès aux dossiers clients, comment calculer les intérêts selon plusieurs méthodes différentes, comment notifier les clients lors d’un virement… Au fil des années, vous avez développé des façons de résoudre ces problèmes efficacement. Ces solutions éprouvées, ce sont des patterns — des modèles.
En développement logiciel, un Design Pattern (patron de conception) est exactement cela : une solution réutilisable à un problème récurrent dans la conception de logiciels. Ce ne sont pas des bibliothèques à importer, ni du code tout fait — ce sont des recettes, des plans de construction que vous adaptez à votre situation.
💡 Un Design Pattern, c’est comme une recette de cuisine : la recette de la tarte tatin ne vous donne pas une tarte, mais elle vous explique comment en faire une. Vous adaptez les ingrédients (les classes) à votre contexte.
En 1994, quatre ingénieurs — Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides — ont publié le livre “Design Patterns: Elements of Reusable Object-Oriented Software”. Ce livre recense 23 patterns fondamentaux. Ses auteurs sont surnommés le Gang of Four (GoF).
Ces 23 patterns sont regroupés en trois familles :
Dans le secteur bancaire, les enjeux sont particulièrement élevés :
Les Design Patterns répondent directement à ces enjeux. Ils permettent de :
Ce cours suppose que vous maîtrisez les bases de Java (classes, héritage, interfaces) et que vous avez déjà utilisé Spring Boot. Voici l’environnement de travail :
Environnement recommandé — Windows ├── JDK 17 (Oracle ou OpenJDK) ├── Spring Boot 3.x ├── Maven ou Gradle ├── IntelliJ IDEA Community ou VS Code + Extension Pack for Java ├── MySQL 8 (via XAMPP ou Docker Desktop) └── Thymeleaf (intégré à Spring Boot)
Vérification de l’environnement :
# Dans le terminal Windows (CMD ou PowerShell) java -version # java version "17.x.x" mvn -version # Apache Maven 3.x.x
Pour les exemples de ce cours, notre projet s’appelle BanqueApp :
# Créer le projet avec Spring Initializr # https://start.spring.io # Paramètres : # - Project : Maven # - Language : Java # - Spring Boot : 3.x # - Java : 17 # - Dependencies : Spring Web, Thymeleaf, Spring Data JPA, MySQL Driver, Lombok
banque-app/ ├── src/main/java/com/banque/ │ ├── BanqueAppApplication.java ← Point d'entrée Spring Boot │ ├── config/ ← Configuration (Beans, Singletons) │ ├── controller/ ← Contrôleurs Spring MVC │ ├── dao/ ← Couche d'accès aux données (DAO) │ ├── entity/ ← Entités JPA (modèles) │ ├── factory/ ← Factories (fabriques) │ ├── service/ ← Services métier │ └── strategy/ ← Stratégies de calcul ├── src/main/resources/ │ ├── application.properties ← Configuration Spring │ └── templates/ ← Vues Thymeleaf └── pom.xml
Dans votre banque, il ne peut y avoir qu’un seul gouverneur de la Banque de France. De même, dans votre application, certains objets ne doivent exister qu’en une seule instance dans toute la JVM :
Sans Singleton, chaque partie du code pourrait créer sa propre instance de ces objets — gaspillage mémoire, incohérences, risques de race conditions.
Singleton : patron créationnel qui garantit qu’une classe n’a qu’une seule instance et fournit un point d’accès global à cette instance.
// ❌ Implémentation naïve — problème en environnement multi-thread public class GestionnaireAudit { private static GestionnaireAudit instance; // L'unique instance // ✅ Constructeur privé : personne ne peut faire new GestionnaireAudit() private GestionnaireAudit() { System.out.println("Gestionnaire d'audit initialisé."); } // ❌ Non thread-safe : deux threads peuvent créer deux instances public static GestionnaireAudit getInstance() { if (instance == null) { instance = new GestionnaireAudit(); } return instance; } }
Problème dans un contexte bancaire avec plusieurs threads (ce qui est toujours le cas) : deux requêtes simultanées peuvent créer deux instances différentes !
// ✅ Singleton thread-safe — La version recommandée en Java moderne public class GestionnaireAudit { // volatile garantit la visibilité entre threads private static volatile GestionnaireAudit instance; private final List<String> journalAudit = new ArrayList<>(); // ✅ Constructeur privé private GestionnaireAudit() {} // ✅ Double-checked locking public static GestionnaireAudit getInstance() { if (instance == null) { // Premier test (sans synchronisation) synchronized (GestionnaireAudit.class) { if (instance == null) { // Deuxième test (avec verrou) instance = new GestionnaireAudit(); } } } return instance; } public synchronized void enregistrer(String action, String utilisateur) { String entree = String.format("[%s] %s — %s", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), utilisateur, action ); journalAudit.add(entree); System.out.println("AUDIT : " + entree); } public List<String> obtenirJournal() { return Collections.unmodifiableList(journalAudit); } }
// ✅ La meilleure implémentation Singleton en Java : l'enum // Avantages : thread-safe de facto, sérialisation gérée, résistant à la réflexion public enum GestionnaireConfiguration { INSTANCE; // L'unique instance private final Properties config = new Properties(); GestionnaireConfiguration() { // Chargement de la configuration au démarrage try (InputStream is = getClass().getResourceAsStream("/application.properties")) { if (is != null) config.load(is); } catch (IOException e) { System.err.println("Erreur chargement config : " + e.getMessage()); } } public String obtenirPropriete(String cle) { return config.getProperty(cle, ""); } public String obtenirPropriete(String cle, String valeurDefaut) { return config.getProperty(cle, valeurDefaut); } } // ✅ Utilisation public class ExempleUtilisation { public void afficherConfig() { String tauxTva = GestionnaireConfiguration.INSTANCE.obtenirPropriete("banque.taux.tva", "20"); System.out.println("Taux TVA : " + tauxTva + "%"); } }
Bonne nouvelle : Spring Boot implémente le Singleton pour vous ! Par défaut, chaque Bean Spring est un Singleton dans le conteneur IoC (Inversion of Control).
// ✅ Dans Spring Boot, tout @Service, @Repository, @Component est Singleton par défaut @Service public class AuditService { private static final Logger log = LoggerFactory.getLogger(AuditService.class); // Spring crée UNE SEULE instance de ce service pour toute l'application public void logAction(String utilisateur, String action) { log.info("[AUDIT] {} : {}", utilisateur, action); // En production : persister dans la base, envoyer à un SIEM... } } // Vérification que Spring respecte bien le pattern Singleton : @RestController public class TestController { @Autowired private AuditService auditService1; @Autowired private AuditService auditService2; @GetMapping("/test-singleton") public String testerSingleton() { // auditService1 == auditService2 : TOUJOURS true avec Spring return "Même instance ? " + (auditService1 == auditService2); // → true } }
💡 En Spring Boot, si vous voulez explicitement une nouvelle instance à chaque injection, utilisez l’annotation @Scope("prototype"). Pour une instance par requête HTTP : @Scope("request"). Mais la grande majorité du temps, le Singleton par défaut est ce qu’il vous faut.
@Scope("prototype")
@Scope("request")
// ✅ Singleton Spring : gestionnaire de taux bancaires @Component public class GestionnaireTaux { // Chargé une seule fois au démarrage de l'application private final Map<String, BigDecimal> taux = new HashMap<>(); @PostConstruct // Méthode appelée après l'injection des dépendances public void initialiser() { taux.put("LIVRET_A", new BigDecimal("0.030")); // 3,0% taux.put("ASSURANCE_VIE", new BigDecimal("0.025")); // 2,5% taux.put("CREDIT_IMMO", new BigDecimal("0.040")); // 4,0% taux.put("CREDIT_CONSO", new BigDecimal("0.065")); // 6,5% System.out.println("Taux bancaires chargés."); } public BigDecimal obtenirTaux(String typeProduit) { return taux.getOrDefault(typeProduit, BigDecimal.ZERO); } public Map<String, BigDecimal> obtenirTousTaux() { return Collections.unmodifiableMap(taux); } }
┌──────────────────────────────────────────┐ │ GestionnaireAudit │ ├──────────────────────────────────────────┤ │ - instance : GestionnaireAudit [static] │ ├──────────────────────────────────────────┤ │ - GestionnaireAudit() [private] │ │ + getInstance() : GestionnaireAudit │ │ + enregistrer(action, utilisateur) │ └──────────────────────────────────────────┘ Client A ──▶ getInstance() ──▶ ┐ ├── Même objet en mémoire Client B ──▶ getInstance() ──▶ ┘
Dans une application bancaire, vous avez besoin d’accéder aux données des comptes, des clients, des transactions. Sans organisation, vos méthodes de calcul métier se mélangent avec les requêtes SQL :
// ❌ SANS DAO — Code métier mélangé avec l'accès aux données public class VirementService { public void effectuerVirement(long idCompteSource, long idCompteDest, BigDecimal montant) { // Logique métier mélangée avec SQL : illisible, non testable, non réutilisable Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/banque", "root", ""); PreparedStatement ps = conn.prepareStatement("SELECT solde FROM compte WHERE id = ?"); ps.setLong(1, idCompteSource); ResultSet rs = ps.executeQuery(); // ... 50 lignes de code SQL enchevêtrées avec la logique métier } }
Ce code est un cauchemar : si vous changez de base de données (Oracle → PostgreSQL), tout est à réécrire. Si vous voulez tester la logique de virement sans BDD, c’est impossible.
DAO (Data Access Object) : patron structurel qui sépare la logique d’accès aux données de la logique métier. Le DAO fournit une interface abstraite vers la base de données.
Le principe est simple : créer une interface qui définit les opérations possibles sur une entité (trouver, créer, modifier, supprimer), puis implémenter cette interface pour chaque technologie de persistance.
// ✅ Entité JPA — modèle de données @Entity @Table(name = "compte") public class Compte { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true) private String numero; // Ex : "FR7630001007941234567890185" @Column(nullable = false) private String typeCompte; // "COURANT", "EPARGNE", "LIVRET_A" @Column(precision = 15, scale = 2) private BigDecimal solde; @Column(nullable = false) private Long idClient; @Column(nullable = false) private boolean actif; // ✅ Constructeur, getters, setters, toString... public Compte() {} public Compte(String numero, String typeCompte, BigDecimal solde, Long idClient) { this.numero = numero; this.typeCompte = typeCompte; this.solde = solde; this.idClient = idClient; this.actif = true; } // Getters et setters (ou @Data de Lombok) public Long getId() { return id; } public String getNumero() { return numero; } public BigDecimal getSolde() { return solde; } public void setSolde(BigDecimal solde) { this.solde = solde; } public boolean isActif() { return actif; } public String getTypeCompte() { return typeCompte; } public Long getIdClient() { return idClient; } public void setActif(boolean actif) { this.actif = actif; } }
// ✅ Interface DAO — contrat abstrait, indépendant de la technologie public interface CompteDAO { // Opérations CRUD de base Optional<Compte> trouverParId(Long id); Optional<Compte> trouverParNumero(String numero); List<Compte> trouverTous(); List<Compte> trouverParClient(Long idClient); List<Compte> trouverParType(String typeCompte); Compte sauvegarder(Compte compte); Compte mettreAJour(Compte compte); void supprimer(Long id); // Opérations métier spécifiques List<Compte> trouverComptesSoldeInsuffisant(BigDecimal seuilMontant); BigDecimal calculerSoldeTotal(Long idClient); boolean existeParNumero(String numero); }
// ✅ Implémentation concrète avec JDBC — technologie 1 public class CompteDAOJdbc implements CompteDAO { private final DataSource dataSource; public CompteDAOJdbc(DataSource dataSource) { this.dataSource = dataSource; } @Override public Optional<Compte> trouverParId(Long id) { String sql = "SELECT * FROM compte WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return Optional.of(mapperResultSet(rs)); } } } catch (SQLException e) { throw new RuntimeException("Erreur accès BDD", e); } return Optional.empty(); } @Override public Compte sauvegarder(Compte compte) { String sql = "INSERT INTO compte (numero, type_compte, solde, id_client, actif) VALUES (?, ?, ?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, compte.getNumero()); ps.setString(2, compte.getTypeCompte()); ps.setBigDecimal(3, compte.getSolde()); ps.setLong(4, compte.getIdClient()); ps.setBoolean(5, compte.isActif()); ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { // Retourner le compte avec son ID généré } } } catch (SQLException e) { throw new RuntimeException("Erreur sauvegarde compte", e); } return compte; } // ✅ Méthode utilitaire privée : mapping ResultSet → Compte private Compte mapperResultSet(ResultSet rs) throws SQLException { Compte c = new Compte(); // mapping des colonnes... (voir implémentation complète) return c; } // ... autres méthodes de l'interface @Override public Optional<Compte> trouverParNumero(String numero) { return Optional.empty(); } @Override public List<Compte> trouverTous() { return List.of(); } @Override public List<Compte> trouverParClient(Long idClient) { return List.of(); } @Override public List<Compte> trouverParType(String typeCompte) { return List.of(); } @Override public Compte mettreAJour(Compte compte) { return compte; } @Override public void supprimer(Long id) {} @Override public List<Compte> trouverComptesSoldeInsuffisant(BigDecimal s) { return List.of(); } @Override public BigDecimal calculerSoldeTotal(Long idClient) { return BigDecimal.ZERO; } @Override public boolean existeParNumero(String numero) { return false; } }
// ✅ Implémentation avec Spring Data JPA — technologie 2 // Spring génère l'implémentation automatiquement ! @Repository public interface CompteDAOJpa extends JpaRepository<Compte, Long>, CompteDAO { // Spring Data génère le SQL depuis le nom de la méthode ! Optional<Compte> findByNumero(String numero); List<Compte> findByIdClient(Long idClient); List<Compte> findByTypeCompte(String typeCompte); boolean existsByNumero(String numero); // Requête JPQL personnalisée @Query("SELECT c FROM Compte c WHERE c.solde < :seuil AND c.actif = true") List<Compte> findComptesSoldeInsuffisant(@Param("seuil") BigDecimal seuil); @Query("SELECT SUM(c.solde) FROM Compte c WHERE c.idClient = :idClient AND c.actif = true") BigDecimal calculerSoldeTotal(@Param("idClient") Long idClient); }
// ✅ La couche Service utilise le DAO via son interface — jamais l'implémentation concrète @Service @Transactional public class CompteService { private final CompteDAO compteDAO; // ← Interface, pas l'implémentation ! private final AuditService auditService; // Spring injecte automatiquement l'implémentation disponible (JPA, JDBC, Mock...) public CompteService(CompteDAO compteDAO, AuditService auditService) { this.compteDAO = compteDAO; this.auditService = auditService; } public Compte ouvrirCompte(String typeCompte, Long idClient, BigDecimal soldeInitial) { String numero = genererNumeroCompte(); Compte compte = new Compte(numero, typeCompte, soldeInitial, idClient); Compte sauvegarde = compteDAO.sauvegarder(compte); auditService.logAction("SYSTEME", "Ouverture compte " + numero + " type " + typeCompte); return sauvegarde; } public void effectuerVirement(String numeroSource, String numeroDest, BigDecimal montant) { Compte source = compteDAO.trouverParNumero(numeroSource) .orElseThrow(() -> new RuntimeException("Compte source introuvable : " + numeroSource)); Compte destination = compteDAO.trouverParNumero(numeroDest) .orElseThrow(() -> new RuntimeException("Compte destination introuvable : " + numeroDest)); if (source.getSolde().compareTo(montant) < 0) { throw new RuntimeException("Solde insuffisant sur " + numeroSource); } source.setSolde(source.getSolde().subtract(montant)); destination.setSolde(destination.getSolde().add(montant)); compteDAO.mettreAJour(source); compteDAO.mettreAJour(destination); auditService.logAction("VIREMENT", String.format("Virement %.2f€ de %s vers %s", montant, numeroSource, numeroDest)); } private String genererNumeroCompte() { return "FR76" + System.currentTimeMillis(); } }
// ✅ Test unitaire SANS base de données — possible grâce au DAO class CompteServiceTest { @Test void testVirementInsuffisant() { // On crée un FAUX DAO en mémoire (Mock) CompteDAO fakeDao = new CompteDAO() { private final Map<String, Compte> comptes = new HashMap<>(); @Override public Optional<Compte> trouverParNumero(String num) { return Optional.ofNullable(comptes.get(num)); } @Override public Compte mettreAJour(Compte c) { comptes.put(c.getNumero(), c); return c; } // ... autres méthodes avec implémentation vide ou minimale @Override public Optional<Compte> trouverParId(Long id) { return Optional.empty(); } @Override public List<Compte> trouverTous() { return List.of(); } @Override public Compte sauvegarder(Compte c) { comptes.put(c.getNumero(), c); return c; } @Override public List<Compte> trouverParClient(Long id) { return List.of(); } @Override public List<Compte> trouverParType(String t) { return List.of(); } @Override public void supprimer(Long id) {} @Override public List<Compte> trouverComptesSoldeInsuffisant(BigDecimal s) { return List.of(); } @Override public BigDecimal calculerSoldeTotal(Long id) { return BigDecimal.ZERO; } @Override public boolean existeParNumero(String n) { return false; } }; // Créer des comptes de test Compte source = new Compte("FR7600001", "COURANT", new BigDecimal("100.00"), 1L); fakeDao.sauvegarder(source); CompteService service = new CompteService(fakeDao, new AuditService()); // Tenter un virement de 200€ avec seulement 100€ disponibles assertThrows(RuntimeException.class, () -> service.effectuerVirement("FR7600001", "FR7600002", new BigDecimal("200.00"))); } }
┌──────────────┐ utilise ┌──────────────┐ │ CompteService│ ─────────────▶ │ CompteDAO │ ← Interface └──────────────┘ └──────┬───────┘ │ implémente ┌────────────────┼──────────────────┐ ▼ ▼ ▼ ┌───────────────┐ ┌─────────────┐ ┌──────────────┐ │ CompteDAOJdbc │ │CompteDAOJpa │ │CompteDAOMock │ │ (production) │ │(production) │ │ (test) │ └───────────────┘ └─────────────┘ └──────────────┘
Dans une banque, il existe plusieurs types de comptes : courant, épargne, livret A, PEL, PEA… Chaque type a ses propres règles : plafond de dépôt, taux, conditions d’ouverture. Sans Factory, la création de ces objets serait dispersée partout dans le code :
// ❌ SANS FACTORY — création dispersée et rigide if (type.equals("COURANT")) { compte = new CompteCourant(client, 0); } else if (type.equals("EPARGNE")) { compte = new CompteEpargne(client, 0.025); } else if (type.equals("LIVRET_A")) { compte = new CompteLivretA(client, 0.03, 22950); // plafond réglementaire } // Ce bloc se répète dans 10 endroits du code → maintenance cauchemardesque
Quand un nouveau type de compte arrive (compte professionnel, compte jeune…), il faut modifier tous ces blocs if-else dans tout le code.
if-else
Factory (Fabrique) : patron créationnel qui délègue la création d’objets à une classe spécialisée. Le code client demande un objet sans savoir exactement quelle classe concrète sera instanciée.
Il existe plusieurs variantes : Simple Factory, Factory Method, Abstract Factory. Nous allons les explorer progressivement.
// ✅ Classe abstraite commune à tous les types de comptes public abstract class CompteBancaire { protected String numero; protected BigDecimal solde; protected Long idClient; protected String typeCompte; public CompteBancaire(Long idClient) { this.idClient = idClient; this.solde = BigDecimal.ZERO; this.numero = genererNumero(); } // Méthodes communes à tous les comptes public abstract BigDecimal calculerInterets(); public abstract BigDecimal getMontantMaxVirement(); public abstract String getDescription(); public void deposer(BigDecimal montant) { if (montant.compareTo(BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("Le montant doit être positif."); this.solde = this.solde.add(montant); } public void retirer(BigDecimal montant) { if (montant.compareTo(this.solde) > 0) throw new RuntimeException("Solde insuffisant."); this.solde = this.solde.subtract(montant); } private String genererNumero() { return "FR76" + typeCompte + System.nanoTime() % 1000000; } // Getters public String getNumero() { return numero; } public BigDecimal getSolde() { return solde; } public Long getIdClient() { return idClient; } public String getTypeCompte() { return typeCompte; } }
// ✅ Compte courant public class CompteCourant extends CompteBancaire { private static final BigDecimal PLAFOND_VIREMENT = new BigDecimal("50000.00"); public CompteCourant(Long idClient) { super(idClient); this.typeCompte = "COURANT"; } @Override public BigDecimal calculerInterets() { return BigDecimal.ZERO; // Pas d'intérêts sur le courant } @Override public BigDecimal getMontantMaxVirement() { return PLAFOND_VIREMENT; } @Override public String getDescription() { return "Compte Courant — Virements jusqu'à " + PLAFOND_VIREMENT + "€"; } } // ✅ Livret A public class CompteLivretA extends CompteBancaire { private static final BigDecimal TAUX_ANNUEL = new BigDecimal("0.030"); // 3,0% private static final BigDecimal PLAFOND_DEPOT = new BigDecimal("22950.00"); // Plafond légal public CompteLivretA(Long idClient) { super(idClient); this.typeCompte = "LIVRET_A"; } @Override public BigDecimal calculerInterets() { // Intérêts mensuels = solde * taux / 12 return solde.multiply(TAUX_ANNUEL).divide(new BigDecimal("12"), 2, RoundingMode.HALF_UP); } @Override public BigDecimal getMontantMaxVirement() { return solde; // On ne peut virer que ce qu'on a } @Override public String getDescription() { return String.format("Livret A — Taux : %.1f%% — Plafond dépôt : %.0f€", TAUX_ANNUEL.multiply(BigDecimal.valueOf(100)), PLAFOND_DEPOT); } } // ✅ Compte Épargne public class CompteEpargne extends CompteBancaire { private final BigDecimal tauxAnnuel; public CompteEpargne(Long idClient, BigDecimal tauxAnnuel) { super(idClient); this.typeCompte = "EPARGNE"; this.tauxAnnuel = tauxAnnuel; } @Override public BigDecimal calculerInterets() { return solde.multiply(tauxAnnuel).divide(new BigDecimal("12"), 2, RoundingMode.HALF_UP); } @Override public BigDecimal getMontantMaxVirement() { return solde; } @Override public String getDescription() { return String.format("Compte Épargne — Taux : %.2f%%", tauxAnnuel.multiply(BigDecimal.valueOf(100))); } }
// ✅ Simple Factory — centralise toute la création public class CompteFactory { // Méthode de création centrale public static CompteBancaire creer(String typeCompte, Long idClient) { return switch (typeCompte.toUpperCase()) { case "COURANT" -> new CompteCourant(idClient); case "LIVRET_A" -> new CompteLivretA(idClient); case "EPARGNE" -> new CompteEpargne(idClient, new BigDecimal("0.025")); case "PEL" -> new ComptePEL(idClient); case "PEA" -> new ComptePEA(idClient); default -> throw new IllegalArgumentException("Type de compte inconnu : " + typeCompte); }; } }
// ✅ Utilisation dans le service — le code client ne connaît pas les classes concrètes @Service public class CompteService { public CompteBancaire ouvrirCompte(String typeCompte, Long idClient) { // Une seule ligne — peu importe le type, la factory s'en charge CompteBancaire compte = CompteFactory.creer(typeCompte, idClient); System.out.println("Compte créé : " + compte.getDescription()); return compte; } }
// ✅ Factory Method : chaque sous-classe définit sa propre logique de création public abstract class GestionnaireCompteFactory { // Factory Method — abstraite : les sous-classes définissent QUOI créer protected abstract CompteBancaire creerCompte(Long idClient); // Méthode template — commune à tous : définit COMMENT initialiser public final CompteBancaire ouvrirEtInitialiser(Long idClient, BigDecimal depotInitial) { CompteBancaire compte = creerCompte(idClient); // Délégué à la sous-classe if (depotInitial != null && depotInitial.compareTo(BigDecimal.ZERO) > 0) { compte.deposer(depotInitial); } System.out.println("Compte ouvert : " + compte.getNumero() + " — Solde initial : " + compte.getSolde() + "€"); return compte; } } // ✅ Fabrique concrète pour les particuliers public class GestionnaireParticulier extends GestionnaireCompteFactory { @Override protected CompteBancaire creerCompte(Long idClient) { return new CompteCourant(idClient); // Les particuliers ont un compte courant } } // ✅ Fabrique concrète pour les jeunes (18-25 ans) public class GestionnaireJeune extends GestionnaireCompteFactory { @Override protected CompteBancaire creerCompte(Long idClient) { // Compte courant avec avantages spéciaux pour les jeunes CompteCourant compte = new CompteCourant(idClient); // Appliquer des avantages tarifaires spéciaux... return compte; } } // ✅ Fabrique concrète pour les professionnels public class GestionnaireProfessionnel extends GestionnaireCompteFactory { @Override protected CompteBancaire creerCompte(Long idClient) { return new CompteProfessionnel(idClient); // Type de compte pro avec fonctionnalités étendues } }
// ✅ Factory Spring : utiliser le contexte Spring pour créer les objets @Component public class CompteSpringFactory { // On injecte toutes les implémentations de CompteBancaire disponibles dans Spring // Spring les trouve automatiquement grâce au @Component sur chaque type private final Map<String, CompteCreateur> creatorsParType; public CompteSpringFactory(List<CompteCreateur> createursList) { this.creatorsParType = createursList.stream() .collect(Collectors.toMap( CompteCreateur::getTypeSupporte, Function.identity() )); } public CompteBancaire creer(String type, Long idClient) { CompteCreateur createur = creatorsParType.get(type.toUpperCase()); if (createur == null) { throw new IllegalArgumentException("Type de compte non supporté : " + type); } return createur.creer(idClient); } } // Interface pour chaque créateur public interface CompteCreateur { String getTypeSupporte(); CompteBancaire creer(Long idClient); } // ✅ Chaque type de compte a son propre créateur Spring @Component public class CompteCreateurLivretA implements CompteCreateur { @Override public String getTypeSupporte() { return "LIVRET_A"; } @Override public CompteBancaire creer(Long idClient) { return new CompteLivretA(idClient); } } @Component public class CompteCreateurCourant implements CompteCreateur { @Override public String getTypeSupporte() { return "COURANT"; } @Override public CompteBancaire creer(Long idClient) { return new CompteCourant(idClient); } }
┌────────────────┐ │ CompteFactory │ └───────┬────────┘ creer() │ ─────────────┤ │ ┌──────────┼──────────┐ ▼ ▼ ▼ ┌───────────┐ ┌──────────┐ ┌──────────┐ │ Compte │ │CompteLiv.│ │ Compte │ │ Courant │ │ A │ │ Epargne │ └───────────┘ └──────────┘ └──────────┘ ▲ ▲ ▲ └──────────┴──────────┘ implémentent CompteBancaire (abstract)
Imaginez le calcul des frais bancaires : pour un client standard, on applique une règle ; pour un client premium, une autre ; pour un client professionnel, encore une autre. Si ce calcul est codé avec des if-else, ajouter un nouveau type de client implique de modifier le code existant — risqué en production bancaire.
Strategy : patron comportemental qui définit une famille d’algorithmes interchangeables. Le client utilise l’algorithme via une interface, sans savoir lequel est actif.
// ✅ Interface Strategy — contrat de tous les algorithmes de calcul public interface StratégieCalculFrais { BigDecimal calculerFraisVirement(BigDecimal montant); BigDecimal calculerFraisRetrait(BigDecimal montant); BigDecimal calculerFraisAnnuels(); String getNomTarification(); } // ✅ Stratégie 1 : Client Standard @Component("fraisStandard") public class StrategieClientStandard implements StratégieCalculFrais { @Override public BigDecimal calculerFraisVirement(BigDecimal montant) { // 0,5% du montant, minimum 0,50€, maximum 15€ BigDecimal frais = montant.multiply(new BigDecimal("0.005")); return frais.max(new BigDecimal("0.50")).min(new BigDecimal("15.00")); } @Override public BigDecimal calculerFraisRetrait(BigDecimal montant) { return new BigDecimal("0.50"); // Forfait fixe } @Override public BigDecimal calculerFraisAnnuels() { return new BigDecimal("24.00"); // 24€ / an } @Override public String getNomTarification() { return "Standard"; } } // ✅ Stratégie 2 : Client Premium @Component("fraisPremium") public class StrategieClientPremium implements StratégieCalculFrais { @Override public BigDecimal calculerFraisVirement(BigDecimal montant) { return BigDecimal.ZERO; // Virements gratuits ! } @Override public BigDecimal calculerFraisRetrait(BigDecimal montant) { return BigDecimal.ZERO; // Retraits gratuits } @Override public BigDecimal calculerFraisAnnuels() { return new BigDecimal("120.00"); // Forfait premium 120€/an } @Override public String getNomTarification() { return "Premium"; } } // ✅ Stratégie 3 : Client Professionnel @Component("fraisProfessionnel") public class StrategieClientProfessionnel implements StratégieCalculFrais { @Override public BigDecimal calculerFraisVirement(BigDecimal montant) { // 0,1% du montant, sans plafond return montant.multiply(new BigDecimal("0.001")); } @Override public BigDecimal calculerFraisRetrait(BigDecimal montant) { return BigDecimal.ZERO; } @Override public BigDecimal calculerFraisAnnuels() { return new BigDecimal("360.00"); // Forfait pro 360€/an } @Override public String getNomTarification() { return "Professionnel"; } }
// ✅ Contexte : le service qui utilise la stratégie @Service public class CalculFraisService { // Map de toutes les stratégies injectées par Spring private final Map<String, StratégieCalculFrais> strategies; public CalculFraisService(Map<String, StratégieCalculFrais> strategies) { this.strategies = strategies; } public BigDecimal calculerFraisVirement(String typeClient, BigDecimal montant) { StratégieCalculFrais strategie = obtenirStrategie(typeClient); BigDecimal frais = strategie.calculerFraisVirement(montant); System.out.printf("Frais virement [%s] pour %.2f€ : %.2f€%n", strategie.getNomTarification(), montant, frais); return frais; } private StratégieCalculFrais obtenirStrategie(String typeClient) { String cle = switch (typeClient.toUpperCase()) { case "PREMIUM" -> "fraisPremium"; case "PROFESSIONNEL" -> "fraisProfessionnel"; default -> "fraisStandard"; }; return strategies.getOrDefault(cle, strategies.get("fraisStandard")); } }
Une banque doit notifier ses clients lors d’événements : dépassement de découvert, virement reçu, tentative de connexion suspecte… Ces notifications peuvent prendre plusieurs formes : email, SMS, notification push, alerte interne de sécurité.
Observer : patron comportemental qui définit une relation 1-N entre objets. Quand l’objet observé change d’état, tous ses observateurs sont notifiés automatiquement.
// ✅ Événement Spring — l'objet qui déclenche la notification public class EvenementTransaction extends ApplicationEvent { public enum TypeEvenement { VIREMENT_EMIS, VIREMENT_RECU, DEPOT, RETRAIT, DECOUVERTE_SUSPECTE, SOLDE_FAIBLE } private final String numeroCompte; private final BigDecimal montant; private final TypeEvenement typeEvenement; private final Long idClient; public EvenementTransaction(Object source, String numeroCompte, BigDecimal montant, TypeEvenement type, Long idClient) { super(source); this.numeroCompte = numeroCompte; this.montant = montant; this.typeEvenement = type; this.idClient = idClient; } // Getters public String getNumeroCompte() { return numeroCompte; } public BigDecimal getMontant() { return montant; } public TypeEvenement getTypeEvenement() { return typeEvenement; } public Long getIdClient() { return idClient; } }
// ✅ Observateur 1 : notification par email @Component public class ObservateurEmail { @EventListener public void surEvenementTransaction(EvenementTransaction evenement) { String message = switch (evenement.getTypeEvenement()) { case VIREMENT_RECU -> String.format("Vous avez reçu un virement de %.2f€ sur votre compte %s.", evenement.getMontant(), evenement.getNumeroCompte()); case SOLDE_FAIBLE -> String.format("Alerte : votre solde est bas (%.2f€ restants).", evenement.getMontant()); case DECOUVERTE_SUSPECTE -> "Alerte sécurité : activité suspecte détectée sur votre compte."; default -> null; }; if (message != null) { System.out.println("[EMAIL] → Client " + evenement.getIdClient() + " : " + message); // En production : emailService.envoyer(clientEmail, "Alerte compte", message); } } } // ✅ Observateur 2 : audit interne (toutes les transactions) @Component public class ObservateurAudit { @EventListener @Async // Asynchrone : ne bloque pas la transaction public void surEvenementTransaction(EvenementTransaction evenement) { System.out.printf("[AUDIT] %s — Compte %s — Montant : %.2f€ — Client : %d%n", evenement.getTypeEvenement(), evenement.getNumeroCompte(), evenement.getMontant(), evenement.getIdClient() ); // En production : persister dans la table audit } } // ✅ Observateur 3 : détection de fraude @Component public class ObservateurAntiFraude { private static final BigDecimal SEUIL_ALERTE = new BigDecimal("10000.00"); @EventListener public void surEvenementTransaction(EvenementTransaction evenement) { if (evenement.getMontant().compareTo(SEUIL_ALERTE) > 0 && evenement.getTypeEvenement() == EvenementTransaction.TypeEvenement.VIREMENT_EMIS) { System.out.printf("[ANTI-FRAUDE] ⚠️ Virement important détecté : %.2f€ sur %s%n", evenement.getMontant(), evenement.getNumeroCompte()); // En production : alerter le service de conformité } } }
// ✅ Publication de l'événement depuis le service @Service public class TransactionService { private final ApplicationEventPublisher eventPublisher; private final CompteDAO compteDAO; public TransactionService(ApplicationEventPublisher eventPublisher, CompteDAO compteDAO) { this.eventPublisher = eventPublisher; this.compteDAO = compteDAO; } public void effectuerVirement(String numeroSource, String numeroDest, BigDecimal montant, Long idClient) { // Logique de virement... // ✅ Publier l'événement — tous les observateurs sont notifiés automatiquement eventPublisher.publishEvent(new EvenementTransaction( this, numeroSource, montant, EvenementTransaction.TypeEvenement.VIREMENT_EMIS, idClient )); // Vérification du solde restant Compte source = compteDAO.trouverParNumero(numeroSource).orElseThrow(); if (source.getSolde().compareTo(new BigDecimal("100")) < 0) { eventPublisher.publishEvent(new EvenementTransaction( this, numeroSource, source.getSolde(), EvenementTransaction.TypeEvenement.SOLDE_FAIBLE, idClient )); } } }
Créer une demande de prêt immobilier, c’est assembler de nombreux paramètres : montant, durée, taux, garanties, assurance, co-emprunteur… Un constructeur Java avec 15 paramètres est illisible et source d’erreurs. Le Builder résout ce problème.
Builder : patron créationnel qui construit des objets complexes étape par étape. Il sépare la construction de l’objet de sa représentation finale.
// ✅ Objet complexe construit par le Builder public class DemandePret { // Champs obligatoires private final Long idClient; private final BigDecimal montant; private final int dureeMois; private final String typePret; // "IMMO", "CONSO", "AUTO" // Champs optionnels private final BigDecimal tauxNegocié; private final boolean assuranceDecés; private final boolean assuranceInvalidite; private final String coEmprunteurNom; private final BigDecimal apportPersonnel; private final String objetFinancement; // ✅ Constructeur privé — seul le Builder peut créer cet objet private DemandePret(Builder builder) { this.idClient = builder.idClient; this.montant = builder.montant; this.dureeMois = builder.dureeMois; this.typePret = builder.typePret; this.tauxNegocié = builder.tauxNegocié; this.assuranceDecés = builder.assuranceDecés; this.assuranceInvalidite = builder.assuranceInvalidite; this.coEmprunteurNom = builder.coEmprunteurNom; this.apportPersonnel = builder.apportPersonnel; this.objetFinancement = builder.objetFinancement; } // ✅ Classe Builder statique imbriquée public static class Builder { // Champs obligatoires private final Long idClient; private final BigDecimal montant; private final int dureeMois; private final String typePret; // Champs optionnels avec valeurs par défaut private BigDecimal tauxNegocié = null; // Taux calculé automatiquement private boolean assuranceDecés = true; // Activée par défaut private boolean assuranceInvalidite = false; private String coEmprunteurNom = null; private BigDecimal apportPersonnel = BigDecimal.ZERO; private String objetFinancement = ""; // Constructeur du Builder avec les champs obligatoires public Builder(Long idClient, BigDecimal montant, int dureeMois, String typePret) { if (idClient == null || montant == null || dureeMois <= 0) throw new IllegalArgumentException("Paramètres obligatoires manquants."); if (montant.compareTo(BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("Le montant doit être positif."); this.idClient = idClient; this.montant = montant; this.dureeMois = dureeMois; this.typePret = typePret; } // ✅ Méthodes "fluent" — chacune retourne le Builder pour chaîner les appels public Builder avecTauxNegocié(BigDecimal taux) { this.tauxNegocié = taux; return this; } public Builder avecAssuranceDecés(boolean active) { this.assuranceDecés = active; return this; } public Builder avecAssuranceInvalidite(boolean active) { this.assuranceInvalidite = active; return this; } public Builder avecCoEmprunteur(String nomCoEmprunteur) { this.coEmprunteurNom = nomCoEmprunteur; return this; } public Builder avecApportPersonnel(BigDecimal apport) { this.apportPersonnel = apport; return this; } public Builder pourFinancement(String objet) { this.objetFinancement = objet; return this; } // ✅ Méthode finale qui construit l'objet public DemandePret construire() { // Validation finale avant construction if ("IMMO".equals(typePret) && apportPersonnel.compareTo(BigDecimal.ZERO) == 0) { System.out.println("Avertissement : apport personnel recommandé pour prêt immobilier."); } return new DemandePret(this); } } // Getters public Long getIdClient() { return idClient; } public BigDecimal getMontant() { return montant; } public int getDureeMois() { return dureeMois; } public String getTypePret() { return typePret; } public BigDecimal getTauxNegocié() { return tauxNegocié; } public String getCoEmprunteurNom() { return coEmprunteurNom; } public BigDecimal getApportPersonnel() { return apportPersonnel; } public String getObjetFinancement() { return objetFinancement; } @Override public String toString() { return String.format("DemandePret{client=%d, type=%s, montant=%.0f€, durée=%d mois, apport=%.0f€}", idClient, typePret, montant, dureeMois, apportPersonnel); } }
// ✅ Utilisation du Builder — lisible comme une phrase ! public class ExempleBuilder { public void creerDemandesPret() { // Prêt immobilier complet avec toutes les options DemandePret pretImmo = new DemandePret.Builder( 1001L, new BigDecimal("250000"), 300, // 25 ans "IMMO" ) .avecApportPersonnel(new BigDecimal("50000")) .avecCoEmprunteur("Marie Dupont") .avecAssuranceDecés(true) .avecAssuranceInvalidite(true) .avecTauxNegocié(new BigDecimal("0.038")) .pourFinancement("Résidence principale — Lyon 6ème") .construire(); // Prêt consommation simple — juste les obligatoires DemandePret pretConso = new DemandePret.Builder( 1002L, new BigDecimal("15000"), 60, // 5 ans "CONSO" ) .construire(); System.out.println("Demande créée : " + pretImmo); System.out.println("Demande créée : " + pretConso); } }
💡 Le Builder de Lombok (@Builder) génère automatiquement ce code pour vous. C’est ce que vous verrez le plus souvent dans les projets Spring Boot modernes.
@Builder
// ✅ Version avec Lombok @Builder — beaucoup plus concis ! @Data @Builder @NoArgsConstructor @AllArgsConstructor @Entity public class Client { private Long id; private String nom; private String prenom; private String email; private String telephone; private String segmentation; // "STANDARD", "PREMIUM", "PRO" private LocalDate dateNaissance; } // Utilisation Lombok Builder Client client = Client.builder() .nom("Dupont") .prenom("Jean") .email("jean.dupont@email.fr") .segmentation("PREMIUM") .build();
Un compte bancaire peut avoir des fonctionnalités additionnelles : assurance perte d’emploi, protection découvert, accès lounge aéroport pour une carte premium… Ces options s’ajoutent dynamiquement sans changer la classe de base.
Decorator : patron structurel qui ajoute dynamiquement des comportements à un objet en l’enveloppant dans des objets décorateurs, sans modifier la classe originale.
// ✅ Interface commune public interface ServiceBancaire { String getDescription(); BigDecimal getCoutMensuel(); String getAvantages(); } // ✅ Composant de base : compte standard public class CompteStandardService implements ServiceBancaire { @Override public String getDescription() { return "Compte Bancaire Standard"; } @Override public BigDecimal getCoutMensuel() { return new BigDecimal("2.00"); } @Override public String getAvantages() { return "CB classique, virements SEPA"; } } // ✅ Décorateur abstrait — base de tous les décorateurs public abstract class ServiceBancaireDecorator implements ServiceBancaire { protected final ServiceBancaire serviceDécore; // L'objet à décorer public ServiceBancaireDecorator(ServiceBancaire service) { this.serviceDécore = service; } @Override public String getDescription() { return serviceDécore.getDescription(); } @Override public BigDecimal getCoutMensuel() { return serviceDécore.getCoutMensuel(); } @Override public String getAvantages() { return serviceDécore.getAvantages(); } } // ✅ Décorateur concret : Assurance perte d'emploi public class AssurancePerteDEmploiDecorator extends ServiceBancaireDecorator { public AssurancePerteDEmploiDecorator(ServiceBancaire service) { super(service); } @Override public String getDescription() { return serviceDécore.getDescription() + " + Assurance Perte d'Emploi"; } @Override public BigDecimal getCoutMensuel() { return serviceDécore.getCoutMensuel().add(new BigDecimal("9.90")); } @Override public String getAvantages() { return serviceDécore.getAvantages() + ", Protection revenu en cas de licenciement"; } } // ✅ Décorateur : Protection découvert public class ProtectionDecouvertDecorator extends ServiceBancaireDecorator { public ProtectionDecouvertDecorator(ServiceBancaire service) { super(service); } @Override public String getDescription() { return serviceDécore.getDescription() + " + Protection Découvert"; } @Override public BigDecimal getCoutMensuel() { return serviceDécore.getCoutMensuel().add(new BigDecimal("3.50")); } @Override public String getAvantages() { return serviceDécore.getAvantages() + ", Autorisation découvert 500€ sans frais"; } } // ✅ Décorateur : Carte premium public class CartePremiumDecorator extends ServiceBancaireDecorator { public CartePremiumDecorator(ServiceBancaire service) { super(service); } @Override public String getDescription() { return serviceDécore.getDescription() + " + Carte Visa Premier"; } @Override public BigDecimal getCoutMensuel() { return serviceDécore.getCoutMensuel().add(new BigDecimal("14.90")); } @Override public String getAvantages() { return serviceDécore.getAvantages() + ", Lounge aéroport, assurance voyage, cashback 1%"; } }
// ✅ Utilisation — combinaison dynamique des décorateurs public class ExempleDecorator { public void configurerOffres() { ServiceBancaire compteBase = new CompteStandardService(); System.out.println("Base : " + compteBase.getCoutMensuel() + "€/mois"); // Client qui ajoute uniquement la protection découvert ServiceBancaire avecProtection = new ProtectionDecouvertDecorator(compteBase); System.out.println(avecProtection.getDescription() + " — " + avecProtection.getCoutMensuel() + "€/mois"); // Client Premium avec tout ServiceBancaire offrePremium = new CartePremiumDecorator( new AssurancePerteDEmploiDecorator( new ProtectionDecouvertDecorator(compteBase))); System.out.println("OFFRE PREMIUM : " + offrePremium.getDescription()); System.out.println("Coût mensuel : " + offrePremium.getCoutMensuel() + "€"); System.out.println("Avantages : " + offrePremium.getAvantages()); } }
Dans une banque, vous n’accédez pas directement au coffre-fort. Un agent (le proxy) vérifie vos droits, journalise votre accès, et peut même mettre en cache les informations fréquentes.
Proxy : patron structurel qui fournit un substitut ou intermédiaire à un autre objet. Le proxy contrôle l’accès à l’objet réel.
// ✅ Interface commune public interface ServiceCours { BigDecimal obtenirCoursBourse(String ticker); BigDecimal obtenirTauxChange(String devise); } // ✅ Implémentation réelle — appels coûteux (réseau, latence) @Service("servicesCoursReels") public class ServiceCoursBoursiere implements ServiceCours { @Override public BigDecimal obtenirCoursBourse(String ticker) { System.out.println("[API EXTERNE] Appel coûteux pour : " + ticker); // En production : appel à une API financière (Bloomberg, Reuters...) return new BigDecimal("152.45"); // Valeur simulée } @Override public BigDecimal obtenirTauxChange(String devise) { System.out.println("[API EXTERNE] Appel coûteux pour taux : " + devise); return new BigDecimal("1.085"); // EUR/USD simulé } } // ✅ Proxy avec cache et limitation de fréquence @Service("servicesCoursProxy") @Primary // Spring utilisera ce proxy par défaut public class ServiceCoursProxy implements ServiceCours { private final ServiceCours serviceReel; private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>(); private static final long DUREE_CACHE_MS = 30_000; // 30 secondes public ServiceCoursProxy(@Qualifier("servicesCoursReels") ServiceCours serviceReel) { this.serviceReel = serviceReel; } @Override public BigDecimal obtenirCoursBourse(String ticker) { // ✅ Proxy de cache : retourner la valeur en cache si fraîche String cle = "BOURSE_" + ticker; CacheEntry entree = cache.get(cle); if (entree != null && !entree.estExpiree()) { System.out.println("[CACHE HIT] Cours " + ticker + " servi depuis le cache."); return entree.valeur; } // Cache manqué : appeler le service réel BigDecimal cours = serviceReel.obtenirCoursBourse(ticker); cache.put(cle, new CacheEntry(cours)); return cours; } @Override public BigDecimal obtenirTauxChange(String devise) { String cle = "CHANGE_" + devise; CacheEntry entree = cache.get(cle); if (entree != null && !entree.estExpiree()) return entree.valeur; BigDecimal taux = serviceReel.obtenirTauxChange(devise); cache.put(cle, new CacheEntry(taux)); return taux; } // Classe interne pour les entrées de cache private static class CacheEntry { final BigDecimal valeur; final long horodatage; CacheEntry(BigDecimal valeur) { this.valeur = valeur; this.horodatage = System.currentTimeMillis(); } boolean estExpiree() { return System.currentTimeMillis() - horodatage > DUREE_CACHE_MS; } } }
💡 Spring AOP (Aspect-Oriented Programming) est une implémentation automatique du patron Proxy. Les annotations @Transactional, @Cacheable, @PreAuthorize sont toutes des proxies Spring qui s’intercalent entre l’appelant et la méthode réelle.
@Transactional
@Cacheable
@PreAuthorize
Le traitement d’un virement bancaire suit toujours les mêmes étapes : vérifier le solde, bloquer les fonds, exécuter le transfert, libérer les fonds, notifier. Mais certaines étapes varient selon le type de virement (SEPA, international, interne). Le Template Method permet de fixer la structure tout en délégant les variations.
Template Method : patron comportemental qui définit le squelette d’un algorithme dans une méthode de base, en déléguant certaines étapes aux sous-classes.
// ✅ Classe abstraite avec le template public abstract class TraitementVirement { // ✅ Template Method — la structure est fixe et FINALE public final void executer(String source, String destination, BigDecimal montant) { System.out.println("\n=== Début traitement virement ==="); validerParametres(source, destination, montant); // 1. Commune verifierSolde(source, montant); // 2. Commune appliquerReglesSpecifiques(source, destination, montant); // 3. Variable effectuerTransfert(source, destination, montant); // 4. Variable calculerEtAppliquerFrais(source, montant); // 5. Variable notifier(source, destination, montant); // 6. Commune System.out.println("=== Virement terminé ===\n"); } // Étapes communes à tous les virements (non surchargeables) private void validerParametres(String source, String dest, BigDecimal montant) { if (source == null || dest == null || montant == null) throw new IllegalArgumentException("Paramètres invalides."); System.out.println("✅ Paramètres validés."); } private void verifierSolde(String numeroCompte, BigDecimal montant) { System.out.println("✅ Solde vérifié pour " + numeroCompte); } private void notifier(String source, String dest, BigDecimal montant) { System.out.printf("✅ Notifications envoyées : virement de %.2f€%n", montant); } // Étapes variables — à implémenter dans les sous-classes protected abstract void appliquerReglesSpecifiques(String source, String dest, BigDecimal montant); protected abstract void effectuerTransfert(String source, String dest, BigDecimal montant); protected abstract void calculerEtAppliquerFrais(String numeroCompte, BigDecimal montant); } // ✅ Virement SEPA (Europe) public class VirementSEPA extends TraitementVirement { @Override protected void appliquerReglesSpecifiques(String source, String dest, BigDecimal montant) { System.out.println("🔍 Vérification IBAN européen..."); System.out.println("🔍 Contrôle liste noire SEPA..."); } @Override protected void effectuerTransfert(String source, String dest, BigDecimal montant) { System.out.printf("💶 Virement SEPA exécuté : %s → %s — %.2f€%n", source, dest, montant); } @Override protected void calculerEtAppliquerFrais(String compte, BigDecimal montant) { System.out.println("💰 Frais SEPA : 0,00€ (gratuit dans la zone euro)"); } } // ✅ Virement international (hors SEPA) public class VirementInternational extends TraitementVirement { @Override protected void appliquerReglesSpecifiques(String source, String dest, BigDecimal montant) { System.out.println("🔍 Contrôle réglementaire international (SWIFT)..."); System.out.println("🔍 Vérification conformité AML (anti-blanchiment)..."); System.out.println("🔍 Déclaration obligatoire si montant > 10 000€..."); } @Override protected void effectuerTransfert(String source, String dest, BigDecimal montant) { System.out.printf("🌍 Virement SWIFT exécuté : %s → %s — %.2f€ (J+3)%n", source, dest, montant); } @Override protected void calculerEtAppliquerFrais(String compte, BigDecimal montant) { BigDecimal frais = montant.multiply(new BigDecimal("0.005")).max(new BigDecimal("15.00")); System.out.printf("💰 Frais virement international : %.2f€%n", frais); } }
Les opérations bancaires doivent parfois être annulables (ordre de virement révocable), enregistrées (toute opération doit être tracée), et parfois rejouables (reprise après incident). Le patron Command encapsule chaque opération comme un objet.
Command : patron comportemental qui encapsule une demande sous forme d’objet. Permet l’annulation, la journalisation et la mise en file d’attente des opérations.
// ✅ Interface Command public interface OrdresBancaires { void executer(); void annuler(); String getDescription(); LocalDateTime getHorodatage(); } // ✅ Commande concrète : Virement public class CommandeVirement implements OrdresBancaires { private final CompteService compteService; private final String compteSource; private final String compteDestination; private final BigDecimal montant; private final LocalDateTime horodatage; private boolean estExecute = false; public CommandeVirement(CompteService service, String source, String dest, BigDecimal montant) { this.compteService = service; this.compteSource = source; this.compteDestination = dest; this.montant = montant; this.horodatage = LocalDateTime.now(); } @Override public void executer() { compteService.effectuerVirement(compteSource, compteDestination, montant); estExecute = true; System.out.println("✅ Virement exécuté : " + getDescription()); } @Override public void annuler() { if (!estExecute) throw new IllegalStateException("Ce virement n'a pas encore été exécuté."); // Virement inverse pour annuler compteService.effectuerVirement(compteDestination, compteSource, montant); estExecute = false; System.out.println("↩️ Virement annulé : " + getDescription()); } @Override public String getDescription() { return String.format("Virement %.2f€ de %s vers %s", montant, compteSource, compteDestination); } @Override public LocalDateTime getHorodatage() { return horodatage; } } // ✅ Gestionnaire de commandes (Invoker) avec historique @Service public class GestionnaireOrdres { private final Deque<OrdresBancaires> historiqueOrdres = new ArrayDeque<>(); private final List<OrdresBancaires> fileAttente = new ArrayList<>(); public void ajouterOrdre(OrdresBancaires ordre) { fileAttente.add(ordre); System.out.println("📋 Ordre mis en file : " + ordre.getDescription()); } public void traiterTousLesOrdres() { for (OrdresBancaires ordre : fileAttente) { ordre.executer(); historiqueOrdres.push(ordre); // Empiler pour permettre l'annulation } fileAttente.clear(); } public void annulerDernierOrdre() { if (historiqueOrdres.isEmpty()) { System.out.println("Aucun ordre à annuler."); return; } OrdresBancaires dernierOrdre = historiqueOrdres.pop(); dernierOrdre.annuler(); } public void afficherHistorique() { System.out.println("\n=== Historique des ordres ==="); historiqueOrdres.forEach(o -> System.out.println(" [" + o.getHorodatage() + "] " + o.getDescription()) ); } }
Le MVC (Modèle-Vue-Contrôleur) est le patron de base de toute application web Spring Boot. Il organise le code en trois couches :
// ✅ Contrôleur MVC Spring pour la gestion des comptes @Controller @RequestMapping("/comptes") public class CompteController { private final CompteService compteService; private final ClientService clientService; public CompteController(CompteService compteService, ClientService clientService) { this.compteService = compteService; this.clientService = clientService; } // GET /comptes — Liste de tous les comptes @GetMapping public String listerComptes(Model model) { model.addAttribute("comptes", compteService.trouverTous()); model.addAttribute("titre", "Gestion des Comptes"); return "comptes/liste"; // → templates/comptes/liste.html } // GET /comptes/{id} — Détail d'un compte @GetMapping("/{id}") public String detailCompte(@PathVariable Long id, Model model) { Compte compte = compteService.trouverParId(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Compte introuvable")); model.addAttribute("compte", compte); return "comptes/detail"; } // GET /comptes/nouveau — Formulaire de création @GetMapping("/nouveau") public String formulaireNouveauCompte(Model model) { model.addAttribute("compte", new Compte()); model.addAttribute("typesCompte", List.of("COURANT", "EPARGNE", "LIVRET_A", "PEL")); model.addAttribute("clients", clientService.trouverTous()); return "comptes/formulaire"; } // POST /comptes — Créer un compte @PostMapping public String creerCompte(@ModelAttribute @Valid Compte compte, BindingResult bindingResult, RedirectAttributes redirectAttributes) { if (bindingResult.hasErrors()) { return "comptes/formulaire"; // Réafficher avec erreurs } compteService.ouvrirCompte(compte.getTypeCompte(), compte.getIdClient(), compte.getSolde()); redirectAttributes.addFlashAttribute("succes", "Compte créé avec succès !"); return "redirect:/comptes"; } // POST /comptes/virement — Effectuer un virement @PostMapping("/virement") public String effectuerVirement(@RequestParam String compteSource, @RequestParam String compteDestination, @RequestParam BigDecimal montant, RedirectAttributes redirectAttributes) { try { compteService.effectuerVirement(compteSource, compteDestination, montant); redirectAttributes.addFlashAttribute("succes", String.format("Virement de %.2f€ effectué avec succès.", montant)); } catch (Exception e) { redirectAttributes.addFlashAttribute("erreur", "Erreur : " + e.getMessage()); } return "redirect:/comptes"; } }
<!-- templates/comptes/liste.html --> <!DOCTYPE html> <html lang="fr" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title th:text="${titre}">Comptes</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> </head> <body> <div class="container mt-4"> <h1 th:text="${titre}" class="mb-4">🏦 Gestion des Comptes</h1> <!-- Message flash de succès --> <div th:if="${succes}" class="alert alert-success alert-dismissible fade show"> <span th:text="${succes}"></span> <button type="button" class="btn-close" data-bs-dismiss="alert"></button> </div> <!-- Message flash d'erreur --> <div th:if="${erreur}" class="alert alert-danger"> <span th:text="${erreur}"></span> </div> <!-- Tableau des comptes --> <div class="card shadow-sm"> <div class="card-header d-flex justify-content-between align-items-center"> <h5 class="mb-0">Liste des comptes</h5> <a th:href="@{/comptes/nouveau}" class="btn btn-primary btn-sm"> + Nouveau compte </a> </div> <div class="card-body p-0"> <table class="table table-striped table-hover mb-0"> <thead class="table-dark"> <tr> <th>Numéro</th> <th>Type</th> <th>Titulaire</th> <th class="text-end">Solde</th> <th>Statut</th> <th>Actions</th> </tr> </thead> <tbody> <tr th:each="compte : ${comptes}"> <td th:text="${compte.numero}" class="font-monospace"></td> <td> <span th:text="${compte.typeCompte}" th:classappend="${compte.typeCompte == 'LIVRET_A'} ? 'badge bg-success' : 'badge bg-secondary'"> </span> </td> <td th:text="${compte.idClient}"></td> <td class="text-end fw-bold" th:text="${#numbers.formatDecimal(compte.solde, 1, 'COMMA', 2, 'POINT')} + ' €'"> </td> <td> <span th:if="${compte.actif}" class="badge bg-success">Actif</span> <span th:unless="${compte.actif}" class="badge bg-danger">Inactif</span> </td> <td> <a th:href="@{/comptes/{id}(id=${compte.id})}" class="btn btn-sm btn-outline-primary">Détail</a> </td> </tr> <tr th:if="${#lists.isEmpty(comptes)}"> <td colspan="6" class="text-center text-muted py-3">Aucun compte trouvé.</td> </tr> </tbody> </table> </div> </div> <!-- Formulaire de virement rapide --> <div class="card shadow-sm mt-4"> <div class="card-header"><h5 class="mb-0">💸 Virement rapide</h5></div> <div class="card-body"> <form th:action="@{/comptes/virement}" method="post" class="row g-3"> <div class="col-md-4"> <label class="form-label">Compte source</label> <input type="text" name="compteSource" class="form-control" placeholder="FR7600001..." required> </div> <div class="col-md-4"> <label class="form-label">Compte destination</label> <input type="text" name="compteDestination" class="form-control" placeholder="FR7600002..." required> </div> <div class="col-md-2"> <label class="form-label">Montant (€)</label> <input type="number" name="montant" class="form-control" step="0.01" min="0.01" required> </div> <div class="col-md-2 d-flex align-items-end"> <button type="submit" class="btn btn-warning w-100">Virer</button> </div> </form> </div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> </body> </html>
L’application BanqueApp intègre tous les patterns vus dans ce cours dans une application Spring Boot cohérente :
GestionnaireTaux
CompteDAO
ClientDAO
TransactionDAO
CompteFactory
StratégieCalculFrais
EvenementTransaction
DemandePret.Builder
ServiceBancaireDecorator
ServiceCoursProxy
TraitementVirement
GestionnaireOrdres
// ✅ Point d'entrée de l'application @SpringBootApplication @EnableAsync // Pour les observateurs asynchrones public class BanqueAppApplication { public static void main(String[] args) { SpringApplication.run(BanqueAppApplication.class, args); } }
# application.properties spring.datasource.url=jdbc:mysql://localhost:3306/banque_app?useSSL=false&serverTimezone=Europe/Paris spring.datasource.username=banque_user spring.datasource.password=BanqueSecure2024! spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect spring.jpa.properties.hibernate.format_sql=true spring.thymeleaf.cache=false spring.thymeleaf.encoding=UTF-8 # Configuration métier banque.taux.livret-a=0.030 banque.taux.credit-immo=0.040 banque.virement.seuil-alerte=10000 banque.session.timeout=1800
// ✅ Scénario complet intégrant tous les patterns @Service public class ScenarioBancaire { private final GestionnaireTaux gestionnaireT; // Singleton Spring private final CompteDAO compteDAO; // DAO private final CompteSpringFactory compteFactory; // Factory private final CalculFraisService calculFrais; // Strategy private final ApplicationEventPublisher events; // Observer private final GestionnaireOrdres ordres; // Command // Injection par constructeur (recommandé Spring Boot) public ScenarioBancaire(GestionnaireTaux gestionnaireT, CompteDAO compteDAO, CompteSpringFactory compteFactory, CalculFraisService calculFrais, ApplicationEventPublisher events, GestionnaireOrdres ordres) { this.gestionnaireT = gestionnaireT; this.compteDAO = compteDAO; this.compteFactory = compteFactory; this.calculFrais = calculFrais; this.events = events; this.ordres = ordres; } public void demonstrationComplete() { System.out.println("\n╔══════════════════════════════════════╗"); System.out.println("║ BanqueApp — Démonstration complète ║"); System.out.println("╚══════════════════════════════════════╝\n"); // 1. SINGLETON — Consulter les taux une seule fois System.out.println("--- 1. SINGLETON : Taux bancaires ---"); BigDecimal tauxLivretA = gestionnaireT.obtenirTaux("LIVRET_A"); System.out.println("Taux Livret A : " + tauxLivretA.multiply(BigDecimal.valueOf(100)) + "%"); // 2. FACTORY — Créer les comptes System.out.println("\n--- 2. FACTORY : Création des comptes ---"); CompteBancaire compteCourant = compteFactory.creer("COURANT", 1001L); CompteBancaire livretA = compteFactory.creer("LIVRET_A", 1001L); System.out.println("Créé : " + compteCourant.getDescription()); System.out.println("Créé : " + livretA.getDescription()); // 3. BUILDER — Créer une demande de prêt System.out.println("\n--- 3. BUILDER : Demande de prêt ---"); DemandePret demande = new DemandePret.Builder(1001L, new BigDecimal("200000"), 240, "IMMO") .avecApportPersonnel(new BigDecimal("40000")) .avecCoEmprunteur("Alice Martin") .avecAssuranceDecés(true) .pourFinancement("Maison à Lyon") .construire(); System.out.println("Demande créée : " + demande); // 4. STRATEGY — Calculer les frais System.out.println("\n--- 4. STRATEGY : Calcul des frais ---"); BigDecimal fraisStd = calculFrais.calculerFraisVirement("STANDARD", new BigDecimal("5000")); BigDecimal fraisPremium = calculFrais.calculerFraisVirement("PREMIUM", new BigDecimal("5000")); System.out.println("Virement 5000€ — Standard : " + fraisStd + "€, Premium : " + fraisPremium + "€"); // 5. OBSERVER — Publier un événement System.out.println("\n--- 5. OBSERVER : Événement transaction ---"); events.publishEvent(new EvenementTransaction( this, "FR7600001", new BigDecimal("15000"), EvenementTransaction.TypeEvenement.VIREMENT_EMIS, 1001L )); // 6. COMMAND — Ordres en file d'attente System.out.println("\n--- 6. COMMAND : File d'ordres ---"); System.out.println("Tous les patterns ont été appliqués avec succès !"); } }
Exercice 1 — Singleton : Gestionnaire de limites de crédit
Créez un Singleton Spring GestionnaireLimites qui :
GestionnaireLimites
Map<String, BigDecimal>
@PostConstruct
obtenirLimite(String typeClient)
Exercice 2 — DAO : Gestion des transactions
Créez le triplet complet pour les transactions :
Transaction
sauvegarder
trouverParCompte(String numero)
trouverParPeriode(LocalDate debut, LocalDate fin)
calculerTotalEntrees(String numero)
TransactionDAOJpa
TransactionService
Exercice 3 — Factory : Catalogue de produits bancaires
Étendez la CompteFactory pour ajouter :
PEL
PEA
TypeCompteInconnuException
Exercice 4 — Strategy : Moteur de scoring crédit
Implémentez un moteur de scoring de crédit avec 3 stratégies :
ScoringConservateur
ScoringStandard
ScoringAssoupli
Créez un service ScoringCreditService qui sélectionne la stratégie selon la politique bancaire du moment.
ScoringCreditService
Exercice 5 — Observer : Système d’alertes multi-canaux
Étendez le système d’événements pour ajouter :
ObservateurSMS
ObservateurConformite
ObservateurStatistiques
Exercice 6 — Builder + DAO : Simulateur de prêt immobilier
Créez une interface Thymeleaf complète permettant de :
Exercice 7 — Architecture complète
Créez une page d’accueil du tableau de bord bancaire qui affiche :
Quel est mon besoin ? │ ├── Créer des objets │ ├── Garantir une seule instance → SINGLETON │ ├── Créer sans connaître la classe concrète → FACTORY │ └── Assembler des objets complexes étape par étape → BUILDER │ ├── Structurer mon code │ ├── Séparer accès aux données et logique métier → DAO │ ├── Ajouter des comportements dynamiquement → DECORATOR │ └── Contrôler l'accès à un objet → PROXY │ └── Gérer les comportements ├── Algorithmes interchangeables → STRATEGY ├── Notifier plusieurs objets d'un changement → OBSERVER ├── Fixer une structure d'algorithme variable → TEMPLATE METHOD └── Encapsuler et annuler des opérations → COMMAND
# Créer le projet Spring Boot (dans le terminal Windows) # Via https://start.spring.io ou : # Compiler le projet mvn clean compile # Lancer les tests mvn test # Lancer l'application mvn spring-boot:run # Accéder à l'application # → http://localhost:8080 # Créer la base MySQL mysql -u root -p CREATE DATABASE banque_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'banque_user'@'localhost' IDENTIFIED BY 'BanqueSecure2024!'; GRANT ALL PRIVILEGES ON banque_app.* TO 'banque_user'@'localhost'; FLUSH PRIVILEGES;
<!-- pom.xml — Dépendances clés --> <dependencies> <!-- Spring Boot Web + MVC --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf — Moteur de templates --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- Spring Data JPA — DAO automatique --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- MySQL Driver --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok — Builder, @Data, @Slf4j --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- Validation des formulaires --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Tests --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
— Fin du cours —