W3docs

Eccezioni personalizzate in Java

Definisci classi di eccezione personalizzate in Java estendendo Exception o RuntimeException per errori specifici del dominio.

Le eccezioni predefinite coprono la maggior parte dei fallimenti generici, ma non sanno nulla del tuo dominio. Quando qualcosa fallisce in un modo specifico per il tuo codice — "utente non trovato", "coupon non valido", "configurazione non sincronizzata" — la mossa giusta è solitamente definire un proprio tipo di eccezione. Le eccezioni personalizzate costano poco da scrivere, rendono gli stack trace autoesplicativi e permettono ai chiamanti di gestire esattamente il fallimento che gli interessa.

La struttura minima

Un'eccezione personalizzata è una classe che estende Exception (o una delle sue sottoclassi). La versione minima utile:

public class UserNotFoundException extends Exception {
  public UserNotFoundException(String message) {
    super(message);
  }
}

Questa è un'eccezione personalizzata, controllata e completa. Puoi usare throw new UserNotFoundException("id=42") da qualsiasi punto, e i chiamanti possono usare catch (UserNotFoundException e).

Controllata o non controllata?

La decisione più importante nella definizione di una classe di eccezione: cosa si estende?

  • extends Exception → controllata. Il compilatore obbliga i chiamanti a gestirla o dichiararla.
  • extends RuntimeException → non controllata. I chiamanti possono gestirla ma non sono obbligati.

La stessa logica delle eccezioni controllate vs. non controllate si applica: estendi Exception quando i chiamanti possono realisticamente recuperare e vuoi costringerli a rifletterci; estendi RuntimeException quando il fallimento rappresenta un bug o una condizione da cui nessun chiamante può ragionevolmente recuperare.

Per le eccezioni di dominio nel codice Java moderno, RuntimeException è la scelta più comune — in parte perché le eccezioni controllate non si compongono bene con stream e lambda, e in parte perché la maggior parte dei fallimenti di dominio risale comunque a un unico gestore di alto livello. Parti con RuntimeException a meno che tu non abbia un motivo specifico per forzare la gestione.

I quattro costruttori

Per convenzione, una classe di eccezione fornisce gli stessi quattro costruttori delle eccezioni predefinite:

public class ConfigLoadException extends RuntimeException {
  public ConfigLoadException() {
    super();
  }
  public ConfigLoadException(String message) {
    super(message);
  }
  public ConfigLoadException(String message, Throwable cause) {
    super(message, cause);
  }
  public ConfigLoadException(Throwable cause) {
    super(cause);
  }
}

Perché tutti e quattro:

  • Senza argomenti — per strumenti e framework che usano la riflessione sulla classe.
  • Solo messaggio — il caso comune nel proprio codice.
  • Messaggio + causa — per avvolgere un'eccezione di basso livello. Il più importante da includere.
  • Solo causa — per quando il messaggio della causa è già descrittivo.

Non è necessario digitare tutti e quattro ogni volta — gli IDE li generano con un tasto — ma saltare le forme con la causa è una vera perdita. Senza di esse non puoi preservare l'eccezione sottostante quando la avvolgi.

Portare uno stato utile

Le stringhe vanno bene, ma i campi personalizzati sono meglio. Se il chiamante potrebbe voler sapere quale utente non è stato trovato, esponilo:

public class UserNotFoundException extends RuntimeException {
  private final String userId;

  public UserNotFoundException(String userId) {
    super("user not found: " + userId);
    this.userId = userId;
  }

  public String getUserId() { return userId; }
}

Ora un blocco catch può fare qualcosa con il fallimento invece di limitarsi a interpretare il messaggio:

catch (UserNotFoundException e) {
  metrics.recordMissingUser(e.getUserId());
  return Response.notFound();
}

Mantieni i campi immutabili (final) e il costruttore minimale. Le eccezioni vengono costruite sul percorso di fallimento — devono essere veloci e non lanciare mai eccezioni a loro volta.

Avvolgere con una causa

La tecnica più utile con le eccezioni personalizzate è tradurre un'eccezione di basso livello in una di dominio, preservando l'originale:

public Config load(Path p) {
  try {
    return parser.parse(Files.readString(p));
  } catch (IOException e) {
    throw new ConfigLoadException("could not read " + p, e);
  } catch (ParseException e) {
    throw new ConfigLoadException("invalid config in " + p, e);
  }
}

Il chiamante vede un unico tipo di eccezione che corrisponde al vocabolario del suo livello. Il fallimento originale non viene perso — è agganciato a getCause() e appare in printStackTrace() sotto una riga Caused by:.

Questo è il modo per mantenere i livelli separati. L'API Config non espone IOException o ParseException; entrambe si traducono in qualcosa che significa "caricamento della configurazione fallito."

Una piccola gerarchia

Quando hai una famiglia di fallimenti correlati, dagli un genitore comune:

public class PaymentException extends RuntimeException {
  public PaymentException(String message)               { super(message); }
  public PaymentException(String message, Throwable c)  { super(message, c); }
}

public class CardDeclinedException extends PaymentException {
  public CardDeclinedException(String message) { super(message); }
}
public class InsufficientFundsException extends PaymentException {
  public InsufficientFundsException(String message) { super(message); }
}
public class FraudCheckFailedException extends PaymentException {
  public FraudCheckFailedException(String message) { super(message); }
}

I chiamanti possono essere specifici (catch (CardDeclinedException)) o generici (catch (PaymentException)) secondo le esigenze. Un genitore comune fornisce anche un singolo import da inserire in una clausola throws quando il metodo potrebbe lanciare una qualsiasi di esse.

Cosa evitare

  • Non estendere Throwable o Error direttamente. Passa sempre attraverso Exception o RuntimeException.
  • Non sovrascrivere getMessage() per calcolare stringhe ad ogni chiamata. Costruisci il messaggio nel costruttore e lascia che la classe genitore lo memorizzi.
  • Non mettere logica nell'eccezione. Esiste per portare informazioni. Il recupero appartiene al catch.
  • Non proliferare. Ogni nuovo tipo di eccezione è un piccolo contratto che i chiamanti potrebbero voler gestire. Se due fallimenti vogliono davvero la stessa gestione, probabilmente vogliono essere lo stesso tipo.

Un esempio pratico

Un piccolo modulo di elaborazione ordini con la propria famiglia di eccezioni. La classe base avvolge i fallimenti di basso livello; le sottoclassi portano i dettagli del dominio; il driver le cattura a diversi livelli di specificità per mostrare come la gerarchia permetta di scegliere.

java— editable, runs on the server

Il driver cattura prima EmptyOrderException (il caso specifico che vuole gestire diversamente), poi OrderException come catch-all per la famiglia. Quando la validazione fallisce, la catena di cause risale all'IllegalStateException originale, così non perdi informazioni quando traduci verso il tipo di dominio.

Cosa c'è dopo

Ora hai tutti i meccanismi. Il capitolo conclusivo riguarda il lato del giudizio — quando lanciare, quando catturare, cosa registrare e i pattern che distinguono il codice di gestione delle eccezioni maturo dal rumore difensivo. Continua con best practice per la gestione delle eccezioni in Java.

Pratica

Pratica
Stai progettando un'eccezione per 'il pagamento è stato rifiutato dall'emittente della carta'. Il chiamante potrebbe voler riprovare, avvisare l'utente o usare una carta diversa. Qual è il design più difendibile?
Stai progettando un'eccezione per 'il pagamento è stato rifiutato dall'emittente della carta'. Il chiamante potrebbe voler riprovare, avvisare l'utente o usare una carta diversa. Qual è il design più difendibile?
Was this page helpful?