Interfacce vs classi astratte in C#: come scegliere senza impazzire (e senza rimpianti)
Scritto da Marco Morello il 25 agosto 2025
💡 TL;DR: Le classi astratte sono un "telaio saldato" (ereditarietà forte, condivisione di logica), le interfacce sono "adattatori universali" (comportamenti plug-and-play). In questa guida vediamo quando usare l'una o l'altra per evitare di riscrivere mezzo software tra sei mesi.
Se c'è una domanda che mi ha tenuto sveglio la notte quando ho iniziato il mio percorso per passare da semplice "scrittore di codice" ad aspirante Architetto Software, è questa:
"Ma qui... ci metto un'interfaccia o una classe astratta?"
Sembra una domanda banale, vero? Sui libri di testo la risposta è sempre cristallina, quasi asettica: "Il Cane è un Animale (classe astratta), ma può essere Addestrabile (interfaccia)". Tutto fila liscio.
Poi però chiudi il libro, accendi il computer e ti trovi davanti al codice vero. Lì non ci sono cani che abbaiano. Ci sono CustomerService, OrderManager, PaymentProcessor e mille requisiti che cambiano ogni settimana. E in quella nebbia, capire quale strada prendere non è affatto semplice.
Col tempo ho imparato una verità scomoda: la scelta sbagliata oggi non ti presenta il conto subito. Magari per due mesi tutto funziona alla grande. Il conto arriva sei mesi dopo, quando il progetto è diventato enorme, il cliente ti chiede una modifica "piccolissima" e tu ti accorgi che per farla devi smontare mezzo software perché hai usato lo strumento sbagliato.
Sto scrivendo questo articolo non come un professore che sale in cattedra, ma come un compagno di viaggio che ha preso diverse buche e vorrebbe segnalartele prima che ci finisca dentro anche tu. Vediamo insieme come evitare le trappole, usando un po' di sana logica pratica.
1. La classe astratta: Il "telaio saldato"
Per capire davvero la classe astratta, dobbiamo sporcarci le mani in officina. Immagina la classe astratta come il telaio portante di un veicolo. È una struttura solida, definita, già parzialmente costruita.
Quando decidi che la tua classe eredita da una classe astratta, stai stabilendo un legame fortissimo, quasi "di sangue". Se hai una classe astratta VeicoloAMotore e crei una classe FiatPanda che eredita da lì, stai dicendo che la tua auto È un veicolo a motore. Punto.
Non è un adesivo che puoi staccare se cambi idea. È una caratteristica strutturale. Una FiatPanda non potrà mai diventare improvvisamente un DocumentoFiscale o un Tostapane, nemmeno se il tuo capo te lo chiedesse in ginocchio.
Il vantaggio della "memoria" (lo stato)
C'è una cosa che le classi astratte possono fare e le interfacce (solitamente) no: avere memoria. Una classe astratta può avere dei campi (variabili) dove memorizzare dati: il numero di telaio, la quantità di benzina, la velocità attuale. Quei dati esistono dentro la classe base e sono condivisi o accessibili dai figli.
Quando la uso?
La uso quando voglio dire: "Ascolta, il grosso del lavoro strutturale l'ho già fatto io. Ho deciso come si gestisce la benzina e come si accende il motore. Tu che erediti devi solo decidere di che colore è la carrozzeria."
È perfetta per applicare il Template Method Pattern: io (padre) decido la procedura, tu (figlio) riempi i dettagli.
public abstract class GeneratoreReport
{
// Questo metodo è il "Capo Reparto".
// Decide L'ORDINE delle operazioni e nessuno può cambiarlo.
// Garantisce che non ti dimentichi di recuperare i dati prima di salvarli.
public void CreaReport()
{
Console.WriteLine("--- Inizio Processo Standard ---");
// La sequenza è scolpita nella pietra qui:
var dati = RecuperaDati();
var report = FormattaDati(dati);
SalvaSuDisco(report);
Console.WriteLine("--- Processo Completato ---");
}
// METODO ASTRATTO: "Non so come si fa, pensaci tu!"
// Ogni report recupera i dati in modo diverso (DB, File, API...)
protected abstract object RecuperaDati();
// METODO VIRTUALE: "Io ho un'idea standard, ma se vuoi cambiala"
protected virtual string FormattaDati(object dati)
{
return $"Dati formattati standard: {dati}";
}
// METODO ASTRATTO: Anche il salvataggio dipende dal figlio (PDF, Excel...)
protected abstract void SalvaSuDisco(string report);
}
Vedi la rigidità? È un pregio e un difetto. Se tutti i tuoi report seguono esattamente quella sequenza, hai vinto: scrivi la logica una volta sola. Ma se domani arriva un report che deve prima salvare e poi formattare... sei fregato. Il telaio non si piega.
2. L'interfaccia: l' "adattatore universale"
Se la classe astratta è il telaio saldato, l'interfaccia è come una presa USB o un casco. Al casco non importa chi lo indossa. Non gli interessa se sotto c'è un pilota professionista, un amatore o un manichino per i crash test. Al casco interessa solo che la testa abbia la forma giusta per entrarci.
L'interfaccia è un contratto di capacità. Se una classe implementa l'interfaccia IGuidabile, sta firmando un foglio in cui dichiara: "Giuro solennemente che so come sterzare e come frenare". Non importa se è una Ferrari, un trattore o un'auto giocattolo. Agli occhi del sistema, sono tutte "cose guidabili".
Il superpotere: l'ereditarietà multipla (simulata)
Qui c'è il vero motivo per cui gli architetti amano le interfacce. In C#, una classe può avere un solo padre (classe base), ma può avere infinite interfacce.
Questo rispecchia la realtà molto meglio delle classi. Pensaci: io sono un Uomo (classe base biologica), ma nel mio quotidiano sono anche un Montatore meccanico, un Motociclista, un Blogger e un Guidatore. Con le interfacce puoi comporre le capacità del tuo oggetto come se fossero accessori che attacchi e stacchi.
// Contratto 1: Chi lo firma sa inviare messaggi
public interface IMessaggero
{
void Invia(string testo);
}
// Contratto 2: Chi lo firma può essere salvato su DB
public interface ISalvabile
{
void SalvaID(int id);
}
// Questa classe non ha legami di parentela rigidi.
// Ma firma ENTRAMBI i contratti.
public class EmailService : IMessaggero, ISalvabile
{
public void Invia(string testo)
{
Console.WriteLine($"Invio email: {testo}");
}
public void SalvaID(int id)
{
Console.WriteLine($"Salvo il log dell'email {id} nel DB");
}
}
La magia qui è il disaccoppiamento. Il resto del tuo programma non chiederà "dammi un EmailService". Chiederà "dammi un IMessaggero". Oggi gli passi l'EmailService. Domani gli passi un SmsService. Dopodomani un PiccioneViaggiatore. Il programma continuerà a funzionare senza dover cambiare una virgola.
3. La chicca che nessuno ti dice: Il "nato pronto" (costruttori)
C'è un dettaglio che spesso sfugge e che distingue chi scrive codice da chi progetta sistemi: il controllo della nascita dell'oggetto.
Immagina di dover gestire dei pagamenti. Ogni pagamento deve avere per forza un CodiceTransazione. Se usi un'Interfaccia, puoi obbligare la classe ad avere la proprietà, ma non puoi obbligare il programmatore a settarla quando crea l'oggetto. Potrebbe creare un pagamento "vuoto" e settare il codice dopo... o dimenticarsene!
Se usi una Classe Astratta, hai un'arma segreta: il costruttore.
public abstract class PagamentoBase
{
public string CodiceTransazione { get; }
// IL TRUCCO: Obbligo chi eredita a passarmi il codice SUBITO.
// Non puoi creare un PagamentoBase senza fornire il codice.
protected PagamentoBase(string codice)
{
if (string.IsNullOrWhiteSpace(codice))
throw new ArgumentException("Il codice è obbligatorio!");
CodiceTransazione = codice;
}
}
public class PagamentoPayPal : PagamentoBase
{
// Il compilatore ti OBBLIGA a chiamare base(codice).
// Non puoi dimenticartene nemmeno se vuoi.
public PagamentoPayPal(string codice) : base(codice)
{
}
}
La lezione: se devi garantire che un oggetto non esista mai in uno stato invalido (es. senza ID), la Classe Astratta è la tua cintura di sicurezza.
4. Le trappole (storie di vita vissuta)
Nel mio viaggio ho preso diverse "buche". Ecco le due più dolorose, sperando di evitarti di spaccare le sospensioni del tuo progetto.
⚠️ Trappola A: "Eredito tutto, così faccio prima!" (Fragile Base Class)
Da Junior, pensavo di essere furbo. Creavo una classe BaseManager gigante e ci mettevo dentro tutto: log, database, utility. Tutte le classi ereditavano da lì. "Che comodità!", pensavo.
Poi ho dovuto cambiare la gestione del database per una sola classe figlia. Ho fatto la modifica e... BOOM. Ho rotto altre 12 classi che non c'entravano nulla. L'ereditarietà è un matrimonio indissolubile. Se il padre starnutisce, i figli si prendono il raffreddore.
⚠️ Trappola B: Il "coltellino svizzero" (Interfacce enormi)
L'altro errore classico: creare un'interfaccia IGestoreTotale con 20 metodi: Salva(), Carica(), InviaEmail(), FaiIlCaffe().
Quando ho dovuto creare una classe semplice che doveva solo stampare, il compilatore mi ha obbligato a implementare anche gli altri 19 metodi! Mi sono trovato con un file pieno di NotImplementedException. Un incubo.
La lezione: meglio 5 interfacce piccole e specializzate (IStampabile, ISalvabile) che una gigante.
5. La strategia dell'architetto: l'approccio ibrido
Arriviamo al dunque. Non è una partita di calcio "Interfaccia contro Classe Astratta". I migliori sistemi le usano insieme.
- Uso l'interfaccia per l'esterno: definisco il "cosa" deve succedere. Il mondo esterno parla solo con l'interfaccia.
- Uso la classe astratta per l'interno: creo una classe base opzionale che fa il lavoro sporco e ripetitivo (DRY).
// 1. Il Volto Pubblico (Pulito, stabile, testabile)
public interface ILogger
{
void LogInfo(string messaggio);
void LogError(string errore);
}
// 2. L'Aiutante Nascosto (Fa la manovalanza)
public abstract class LoggerBase : ILogger
{
public void LogInfo(string messaggio)
{
// Formatta la data uguale per tutti. DRY puro.
var testoFormattato = $"[{DateTime.Now:yyyy-MM-dd HH:mm}] INFO: {messaggio}";
Scrivi(testoFormattato);
}
public abstract void LogError(string errore);
// Metodo protetto: dettaglio implementativo visibile solo ai figli
protected abstract void Scrivi(string stringaFinale);
}
// 3. La Classe Reale
// Eredita dall'aiutante, così si trova il lavoro di formattazione già fatto.
public class FileLogger : LoggerBase
{
protected override void Scrivi(string stringaFinale)
{
System.IO.File.AppendAllText("log.txt", stringaFinale + "\n");
}
public override void LogError(string errore) { /* ... */ }
}
Chi usa la tua libreria vede ILogger ed è felice perché può creare dei Mock per i test. Tu che sviluppi la libreria usi LoggerBase e sei felice perché non devi copiare-incollare la formattazione della data.
Conclusione: il mio consiglio per il tuo viaggio
Se dovessi riassumere tutto in un consiglio da amico davanti a una birra, ti direi:
Nel dubbio, parti con l'Interfaccia.
L'interfaccia è leggera, non ti impegna, è facile da cambiare e fondamentale per scrivere Unit Test. L'ereditarietà con le classi astratte è potente, ma è come saldare due pezzi di metallo: fallo solo se sei sicuro al 100% che quei due pezzi debbano restare attaccati per sempre.
E tu, quale approccio usi di solito? Sei team "Interfaccia Pura" o ami le Classi Astratte? Scrivimi una mail o contattami su LinkedIn: il confronto è il modo migliore per crescere!
🔜 Prossimamente su "Il Viaggio del Programmatore"
Hai notato come l'ereditarietà possa diventare una gabbia dorata? Nel prossimo articolo, smonteremo un altro mito e vedremo perché spesso è meglio "assemblare" oggetti piuttosto che farli ereditare.
Preparati a scoprire la Composition over Inheritance: l'arte di costruire software flessibile come i LEGO!