1. Les interfaces comme contrat : le fondement de l’architecture
En Java (et pas seulement), une interface n’est pas qu’un simple ensemble de méthodes. C’est un contrat : la promesse que toute classe implémentant l’interface supporte un certain comportement. L’interface définit quoi doit être implémenté, et non comment.
Pourquoi est-ce important ?
- Séparation du code en couches. Grâce aux interfaces, nous pouvons séparer « ce que cela fait » de « comment c’est fait ». Par exemple, si vous avez une interface PaymentService, différentes implémentations peuvent traiter le paiement par carte bancaire, PayPal ou cryptomonnaie, mais le code qui appelle pay() ne se soucie pas des détails.
- Flexibilité et extensibilité. Vous pouvez ajouter une nouvelle implémentation de l’interface sans modifier le reste du code. C’est particulièrement important dans les grandes équipes et les projets de longue durée.
- Testabilité. Grâce aux interfaces, il est facile de remplacer des implémentations par des versions de test (mocks) sans toucher au code principal.
Exemple : couche service et DAO
Prenons un exemple classique d’applications métier. Supposons que nous ayons une interface pour travailler avec les utilisateurs :
public interface UserRepository {
User findById(int id);
void save(User user);
}
Selon les cas, nous pouvons implémenter cette interface de différentes manières :
- DatabaseUserRepository — stocke les utilisateurs dans une base de données.
- InMemoryUserRepository — stocke les utilisateurs en mémoire (pratique pour les tests).
- FileUserRepository — enregistre les utilisateurs dans un fichier.
Le code qui travaille avec les utilisateurs ne dépend que de l’interface :
public class UserService {
private final UserRepository userRepository;
// Injection de dépendance via le constructeur
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void registerUser(User user) {
userRepository.save(user);
}
}
Nous pouvons désormais remplacer facilement l’implémentation UserRepository sans modifier le code du service.
2. Dependency Injection (injection de dépendances) et rôle des interfaces
Dependency Injection (DI, injection de dépendances) est un procédé architectural par lequel les dépendances (par exemple, des implémentations d’interfaces) sont fournies à l’objet depuis l’extérieur, généralement via un constructeur ou un setter. Cela permet de construire des applications flexibles, facilement testables et extensibles.
Pourquoi les interfaces sont-elles importantes pour DI ?
Si nous codions en dur l’implémentation, il serait difficile de la remplacer. Grâce aux interfaces, nous pouvons fournir n’importe quelle implémentation sans changer le code principal.
Exemple avec injection de dépendance
public interface NotificationSender {
void send(String message);
}
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Envoi d'e-mail : " + message);
}
}
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Envoi de SMS : " + message);
}
}
// Classe qui utilise NotificationSender
public class NotificationService {
private final NotificationSender sender;
public NotificationService(NotificationSender sender) {
this.sender = sender;
}
public void notifyUser(String message) {
sender.send(message);
}
}
Vous pouvez maintenant tester facilement NotificationService en lui passant, par exemple, un stub au lieu d’un expéditeur réel.
3. Patrons de conception et interfaces
Les interfaces ne concernent pas seulement l’architecture, mais aussi les patrons de conception. Beaucoup de patrons sont impossibles à mettre en œuvre sans interfaces. Examinons les plus populaires.
Observer (Observateur)
Observer est un patron qui permet à un objet (le sujet observé) de notifier d’autres objets (les observateurs) des changements de son état.
Diagramme UML (simplifié) :
+------------------+ +------------------------+
| Subject |<------->| Observer |
+------------------+ +------------------------+
| +addObserver() | | +update() |
| +removeObserver()| +------------------------+
| +notifyObservers()|
+------------------+
Exemple de code :
import java.util.ArrayList;
import java.util.List;
// Interface d'observateur
public interface Observer {
void update(String event);
}
// Interface de sujet
public interface Subject {
void addObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers(String event);
}
// Implémentation du sujet
public class NewsAgency implements Subject {
private List<Observer> observers = new ArrayList<>();
@Override
public void addObserver(Observer observer) {
observers.add(observer);
}
@Override
public void removeObserver(Observer observer) {
observers.remove(observer);
}
@Override
public void notifyObservers(String event) {
for (Observer observer : observers) {
observer.update(event);
}
}
}
// Implémentation de l'observateur
public class NewsReader implements Observer {
private String name;
public NewsReader(String name) {
this.name = name;
}
@Override
public void update(String event) {
System.out.println(name + " a reçu la nouvelle : " + event);
}
}
// Classe principale pour exécuter l'exemple
public class ObserverExample {
public static void main(String[] args) {
// Nous créons une "agence de presse" (sujet)
NewsAgency agency = new NewsAgency();
// Nous créons des observateurs
Observer alice = new NewsReader("Alice");
Observer bob = new NewsReader("Bob");
// Nous abonnons les observateurs aux actualités
agency.addObserver(alice);
agency.addObserver(bob);
// Nous envoyons une actualité
agency.notifyObservers("Une nouvelle version de Java est sortie !");
// Nous retirons un observateur et envoyons une autre actualité
agency.removeObserver(bob);
agency.notifyObservers("Prochaine actualité pour les abonnés");
}
}
Résultat :
Alice a reçu la nouvelle : Une nouvelle version de Java est sortie !
Bob a reçu la nouvelle : Une nouvelle version de Java est sortie !
Alice a reçu la nouvelle : Prochaine actualité pour les abonnés
Strategy (Stratégie)
Strategy est un patron qui permet de choisir un algorithme de comportement à l’exécution, sans modifier le code client.
Diagramme UML (simplifié) :
+------------------+
| Context |
+------------------+
| -strategy: Strat.|
| +setStrategy() |
| +execute() |
+------------------+
|
v
+------------------+
| Strategy |<-------------------------+
+------------------+ |
| +execute() | |
+------------------+ |
^ |
| |
+------------------+ +------------------+ |
| ConcreteA | | ConcreteB |---+
+------------------+ +------------------+
| +execute() | | +execute() |
+------------------+ +------------------+
Exemple de code :
public interface PaymentStrategy {
void pay(int amount);
}
public class CreditCardPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Paiement de " + amount + " RUB par carte bancaire");
}
}
public class PaypalPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Paiement de " + amount + " RUB via PayPal");
}
}
public class OnlineStore {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy strategy) {
this.paymentStrategy = strategy;
}
public void checkout(int amount) {
paymentStrategy.pay(amount);
}
}
// Utilisation:
OnlineStore store = new OnlineStore();
store.setPaymentStrategy(new CreditCardPayment());
store.checkout(1000);
store.setPaymentStrategy(new PaypalPayment());
store.checkout(500);
Résultat :
Paiement de 1000 RUB par carte bancaire
Paiement de 500 RUB via PayPal
Command (Commande)
Command est un patron qui encapsule une requête sous forme d’objet, permettant de passer des actions en paramètres.
Exemple de code :
public interface Command {
void execute();
}
public class LightOnCommand implements Command {
@Override
public void execute() {
System.out.println("Lumière allumée !");
}
}
public class LightOffCommand implements Command {
@Override
public void execute() {
System.out.println("Lumière éteinte !");
}
}
public class RemoteControl {
private Command command;
public void setCommand(Command command) {
this.command = command;
}
public void pressButton() {
command.execute();
}
}
// Utilisation:
RemoteControl remote = new RemoteControl();
remote.setCommand(new LightOnCommand());
remote.pressButton(); // Lumière allumée !
remote.setCommand(new LightOffCommand());
remote.pressButton(); // Lumière éteinte !
4. Avantages de l’utilisation des interfaces dans l’architecture
- Faible couplage (Low Coupling). Le code dépend uniquement de l’interface et non d’une implémentation concrète. Cela facilite le remplacement, les tests et l’extension.
- Testabilité. Il est facile de remplacer une implémentation réelle par une version de test (mock/stub) lors de l’écriture de tests unitaires.
- Extensibilité. On peut ajouter de nouvelles implémentations d’une interface sans modifier le code existant — principe d’ouverture/fermeture (OCP).
- Développement en parallèle. Plusieurs équipes peuvent implémenter différentes parties du système de manière indépendante si elles disposent d’une interface commune.
- Flexibilité de l’architecture. Il est facile d’introduire de nouveaux patrons et approches.
5. Erreurs courantes lors de l’utilisation des interfaces dans l’architecture
Erreur n° 1 : Liaison rigide à l’implémentation.
Si vous utilisez partout des classes concrètes plutôt que des interfaces, toute modification d’implémentation exigera de réécrire du code en de nombreux endroits. Essayez toujours de programmer « au niveau des interfaces ».
Erreur n° 2 : interfaces trop volumineuses (God Interface).
Une interface doit être compacte et n’assumer qu’une seule responsabilité. Il ne faut pas tout entasser dans une seule interface — sinon l’implémentation deviendra lourde et confuse.
Erreur n° 3 : ignorer les avantages de la testabilité.
Si vous n’utilisez pas les interfaces pour substituer des dépendances dans les tests, vos tests peuvent devenir lents et peu fiables, en particulier s’ils travaillent avec de vraies bases de données ou des réseaux.
Erreur n° 4 : plusieurs implémentations, mais pas de DI.
Si vous avez créé plusieurs implémentations d’une interface mais avez codé en dur l’une d’elles, vous perdez toute la flexibilité architecturale. Utilisez l’injection de dépendances (DI) !
GO TO FULL VERSION