1. Le interfacce come contratto: il fondamento dell’architettura
In Java (e non solo) un’interfaccia — non è solo un insieme di metodi. È un contratto: la promessa che qualsiasi classe che implementa l’interfaccia supporta un determinato comportamento. L’interfaccia definisce che cosa deve essere implementato, non come.
Perché è importante?
- Separazione del codice in livelli. Grazie alle interfacce possiamo separare «che cosa fa» da «come lo fa». Per esempio, se hai un’interfaccia PaymentService, diverse implementazioni possono gestire il pagamento con carta di credito, PayPal o criptovaluta, ma il codice che chiama pay() non si preoccupa dei dettagli.
- Flessibilità ed estendibilità. Puoi aggiungere una nuova implementazione dell’interfaccia senza cambiare il resto del codice. Questo è particolarmente importante in team numerosi e in progetti di lunga durata.
- Testabilità. Grazie alle interfacce è facile sostituire le implementazioni con versioni di test (mock), senza toccare il codice principale.
Esempio: service layer e DAO
Consideriamo un esempio classico dalle applicazioni business. Supponiamo di avere un’interfaccia per lavorare con gli utenti:
public interface UserRepository {
User findById(int id);
void save(User user);
}
In contesti diversi possiamo implementare questa interfaccia in modi differenti:
- DatabaseUserRepository — memorizza gli utenti in un database.
- InMemoryUserRepository — memorizza gli utenti in memoria (comodo per i test).
- FileUserRepository — salva gli utenti su file.
Il codice che lavora con gli utenti dipende solo dall’interfaccia:
public class UserService {
private final UserRepository userRepository;
// Iniezione della dipendenza tramite il costruttore
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void registerUser(User user) {
userRepository.save(user);
}
}
Ora possiamo sostituire facilmente l’implementazione di UserRepository senza modificare il codice del servizio.
2. Dependency Injection (iniezione delle dipendenze) e il ruolo delle interfacce
Dependency Injection (DI, iniezione delle dipendenze) — è una tecnica architetturale in cui le dipendenze (per esempio, implementazioni di interfacce) vengono fornite a un oggetto dall’esterno, di solito tramite costruttore o setter. Questo permette di costruire applicazioni flessibili, facilmente testabili ed estendibili.
Perché le interfacce sono importanti per la DI?
Se fissassimo rigidamente l’implementazione nel codice, sostituirla sarebbe difficile. Con le interfacce possiamo fornire qualsiasi implementazione senza cambiare il codice principale.
Esempio con l’iniezione della dipendenza
public interface NotificationSender {
void send(String message);
}
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Invio email: " + message);
}
}
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Invio SMS: " + message);
}
}
// Classe che usa NotificationSender
public class NotificationService {
private final NotificationSender sender;
public NotificationService(NotificationSender sender) {
this.sender = sender;
}
public void notifyUser(String message) {
sender.send(message);
}
}
Ora puoi testare facilmente NotificationService, passando ad esempio uno stub al posto di un vero mittente di notifiche.
3. Pattern di progettazione e interfacce
Le interfacce non riguardano solo l’architettura, ma anche i pattern di progettazione. Molti pattern sono impossibili da realizzare senza interfacce. Vediamo i più popolari.
Observer (Osservatore)
Observer — pattern che permette a un oggetto (soggetto osservato) di notificare altri oggetti (osservatori) dei cambiamenti del suo stato.
Diagramma UML (semplificato):
+------------------+ +------------------------+
| Subject |<------->| Observer |
+------------------+ +------------------------+
| +addObserver() | | +update() |
| +removeObserver()| +------------------------+
| +notifyObservers()|
+------------------+
Esempio di codice:
import java.util.ArrayList;
import java.util.List;
// Interfaccia dell'osservatore
public interface Observer {
void update(String event);
}
// Interfaccia del soggetto
public interface Subject {
void addObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers(String event);
}
// Implementazione del soggetto
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);
}
}
}
// Implementazione dell'osservatore
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 + " ha ricevuto la notizia: " + event);
}
}
// Classe principale per avviare l'esempio
public class ObserverExample {
public static void main(String[] args) {
// Creiamo un'agenzia di notizie (soggetto)
NewsAgency agency = new NewsAgency();
// Creiamo gli osservatori
Observer alice = new NewsReader("Alisa");
Observer bob = new NewsReader("Bob");
// Iscriviamo gli osservatori alle notizie
agency.addObserver(alice);
agency.addObserver(bob);
// Inviamo una notizia
agency.notifyObservers("È stata rilasciata una nuova versione di Java!");
// Rimuoviamo un osservatore e inviamo un'altra notizia
agency.removeObserver(bob);
agency.notifyObservers("Notizia successiva per gli iscritti");
}
}
Risultato:
Alisa ha ricevuto la notizia: È stata rilasciata una nuova versione di Java!
Bob ha ricevuto la notizia: È stata rilasciata una nuova versione di Java!
Strategy (Strategia)
Strategy — pattern che consente di scegliere l’algoritmo di comportamento a runtime senza cambiare il codice del client.
Diagramma UML (semplificato):
+------------------+
| Context |
+------------------+
| -strategy: Strat.|
| +setStrategy() |
| +execute() |
+------------------+
|
v
+------------------+
| Strategy |<-------------------------+
+------------------+ |
| +execute() | |
+------------------+ |
^ |
| |
+------------------+ +------------------+ |
| ConcreteA | | ConcreteB |---+
+------------------+ +------------------+
| +execute() | | +execute() |
+------------------+ +------------------+
Esempio di codice:
public interface PaymentStrategy {
void pay(int amount);
}
public class CreditCardPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Pagamento " + amount + " RUB con carta di credito");
}
}
public class PaypalPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Pagamento " + amount + " RUB tramite PayPal");
}
}
public class OnlineStore {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy strategy) {
this.paymentStrategy = strategy;
}
public void checkout(int amount) {
paymentStrategy.pay(amount);
}
}
// Utilizzo:
OnlineStore store = new OnlineStore();
store.setPaymentStrategy(new CreditCardPayment());
store.checkout(1000);
store.setPaymentStrategy(new PaypalPayment());
store.checkout(500);
Risultato:
Pagamento 1000 RUB con carta di credito
Pagamento 500 RUB tramite PayPal
Command (Comando)
Command — pattern che incapsula una richiesta come oggetto, permettendo di passare le azioni come parametri.
Esempio di codice:
public interface Command {
void execute();
}
public class LightOnCommand implements Command {
@Override
public void execute() {
System.out.println("Luce accesa!");
}
}
public class LightOffCommand implements Command {
@Override
public void execute() {
System.out.println("Luce spenta!");
}
}
public class RemoteControl {
private Command command;
public void setCommand(Command command) {
this.command = command;
}
public void pressButton() {
command.execute();
}
}
// Utilizzo:
RemoteControl remote = new RemoteControl();
remote.setCommand(new LightOnCommand());
remote.pressButton(); // Luce accesa!
remote.setCommand(new LightOffCommand());
remote.pressButton(); // Luce spenta!
4. Vantaggi dell’uso delle interfacce nell’architettura
- Accoppiamento debole (Low Coupling). Il codice dipende solo dall’interfaccia, non da una specifica implementazione. Questo facilita sostituzione, test ed estensione.
- Testabilità. È facile sostituire l’implementazione reale con una di test (mock/stub) durante la scrittura di unit test.
- Estendibilità. Si possono aggiungere nuove implementazioni dell’interfaccia senza modificare il codice esistente — principio Open/Closed (OCP).
- Sviluppo in parallelo. Più team possono implementare in modo indipendente parti diverse del sistema, se hanno un’interfaccia condivisa.
- Flessibilità dell’architettura. È facile introdurre nuovi pattern e approcci.
5. Errori tipici nell’uso delle interfacce nell’architettura
Errore n. 1: Forte dipendenza da un’implementazione.
Se usi ovunque classi concrete invece delle interfacce, ogni cambiamento dell’implementazione richiederà di riscrivere il codice in molti punti. Cerca sempre di programmare «al livello delle interfacce».
Errore n. 2: Interfacce troppo grandi (God Interface).
Un’interfaccia deve essere compatta e responsabile di una sola area. Non dovresti infilarci dentro di tutto — altrimenti l’implementazione diventerà pesante e confusa.
Errore n. 3: Ignorare i vantaggi della testabilità.
Se non usi le interfacce per sostituire le dipendenze nei test, i tuoi test possono diventare lenti e inaffidabili, soprattutto se lavorano con database o reti reali.
Errore n. 4: Più implementazioni, ma assenza di DI.
Se hai creato più implementazioni dell’interfaccia, ma ne hai cablata rigidamente una nel codice, perdi tutta la flessibilità dell’architettura. Usa l’iniezione delle dipendenze (DI)!
GO TO FULL VERSION