Caso d'uso con interfaccia o senza: 317 contro 321 nello stesso progetto
Hai letto che ogni caso d'uso vuole un'interfaccia e la sua implementazione. Lo fai, e dopo venti casi d'uso ti accorgi che per metà di loro l'interfaccia ha un solo implementatore, nessun test la sostituisce, e l'unico effetto è che per leggere il codice devi aprire due file invece di uno. A quel punto la domanda non è più teorica: l'interfaccia la stai scrivendo per una ragione, o per abitudine?
Questo progetto risponde in modo imbarazzante e utile: fa le due cose insieme. Dei 638 file il cui nome finisce per UseCase, 317 sono interfacce e 321 sono classi concrete annotate come servizio, senza nessuna interfaccia sopra. Accanto ci sono 333 file UseCaseImpl. Non è un'incoerenza da nascondere: è il dato da cui si ricava un criterio.
cd backend/src/main/java
echo "file *UseCase.java: $(find . -name '*UseCase.java' | wc -l)"
echo " di cui interfacce: $(grep -l '^public interface' $(find . -name '*UseCase.java') | wc -l)"
echo "file *UseCaseImpl.java: $(find . -name '*UseCaseImpl.java' | wc -l)"
file *UseCase.java: 638
di cui interfacce: 317
file *UseCaseImpl.java: 333
Che cosa fa un caso d'uso, e che cosa non deve fare
Lo strato applicativo è il più grande dei quattro: 1.626 file, dieci volte il dominio. Questo sorprende chi si aspetta che il grosso stia nel modello, e la ragione è che nella divisione adottata qui il caso d'uso porta tre responsabilità che da qualche parte dovevano stare.
La prima è normalizzare l'ingresso: trasformare una stringa vuota in un filtro assente, limitare un numero che arriva fuori scala, decidere che cosa significa un parametro non specificato. La seconda è ordinare le chiamate: quale porta interrogare, in che sequenza, che cosa fare se la prima non trova niente. La terza è tradurre il dominio in una forma pubblicabile, cioè costruire il DTO di risposta.
Quello che il caso d'uso non deve fare è conoscere il trasporto. Non sa che la richiesta è arrivata via HTTP, non costruisce codici di stato, non legge intestazioni. È la differenza che rende lo stesso caso d'uso richiamabile da un lavoro schedulato o da un comando di amministrazione, e il motivo per cui un limite messo qui vale per tutti e un limite messo nel controller vale per uno.
@Override
public Page<QuoteResponse> execute(String author, String theme, int page, int size) {
final String normalizedAuthor = blank(author) ? null : author.trim();
final String normalizedTheme = blank(theme) ? null : theme.trim();
final int cappedSize = Math.min(Math.max(1, size), MAX_SIZE);
return quoteRepository
.findFiltered(normalizedAuthor, normalizedTheme, page, cappedSize)
.map(QuoteResponse::from);
}
Tre righe di normalizzazione, una chiamata, una trasformazione. MAX_SIZE è una costante privata della classe e vale 50; blank è un metodo statico privato di due righe che restituisce vero per null e per le stringhe di soli spazi. La doppia applicazione di Math.min e Math.max è il modo più compatto di avere un intervallo chiuso: senza il max una richiesta con dimensione zero arriverebbe al database come LIMIT 0, che restituisce una pagina vuota e un totale corretto, cioè un esito che sembra un difetto dei dati.
La riga .map(QuoteResponse::from) è il punto in cui il dominio diventa pubblico, e merita una nota. Trasforma il contenuto della pagina conservando i metadati, e usa un riferimento a metodo statico definito sul DTO. Metterlo sul DTO invece che sul dominio è deliberato: il dominio non deve sapere che esiste una rappresentazione pubblica, mentre la rappresentazione pubblica deve conoscere il dominio per forza.
L'altra metà: il caso d'uso senza interfaccia
Dall'altro lato del progetto ci sono 321 classi concrete, e questa è una delle più corte: calcola l'imposta sul valore aggiunto ceca su un importo netto. Ventidue righe, nessuna interfaccia, nessuna porta, nessuna dipendenza iniettata.
@Service
public class CzDphUseCase {
private static final int SCALE = 2;
private static final RoundingMode RM = RoundingMode.HALF_UP;
public CzDphResponse calculate(CzDphRequest req) {
BigDecimal net = req.netAmount().setScale(SCALE, RM);
String type = req.dphType() != null ? req.dphType().toLowerCase() : "standard";
BigDecimal rate = "reduced".equals(type) ? TaxBracketsCZ.DPH_REDUCED : TaxBracketsCZ.DPH_STANDARD;
BigDecimal dph = net.multiply(rate).setScale(SCALE, RM);
return new CzDphResponse(net, rate, dph, net.add(dph).setScale(SCALE, RM));
}
}
SCALE e RM sono le due proprietà che cambierai se adatti questo schema a un'altra giurisdizione, e vanno capite insieme. setScale(2, RoundingMode.HALF_UP) fissa due decimali e arrotonda la metà verso l'alto; RoundingMode arriva da java.math e offre otto modi diversi. Se ometti l'arrotondamento su una BigDecimal che non si divide in modo esatto, il metodo solleva ArithmeticException invece di arrotondare da sé, ed è un errore che compare solo su certi importi.
La scelta di HALF_UP non è neutra: è la regola che la maggior parte delle amministrazioni fiscali europee applica agli importi monetari, ed è diversa da HALF_EVEN, che è il comportamento predefinito di molti linguaggi e che arrotonda verso il numero pari più vicino. Su un singolo importo la differenza è un centesimo; su un riepilogo annuale la differenza è un documento che non quadra.
Ho eseguito la classe con tre ingressi, compilando con OpenJDK 21.0.12.1 il 31 ottobre 2026, e il terzo risultato è quello interessante:
tipo assente -> CzDphResponse[netAmount=1000.00, dphRate=0.21, dphAmount=210.00, grossAmount=1210.00]
tipo REDUCED -> CzDphResponse[netAmount=1000.00, dphRate=0.12, dphAmount=120.00, grossAmount=1120.00]
tipo ignoto -> CzDphResponse[netAmount=1000.00, dphRate=0.21, dphAmount=210.00, grossAmount=1210.00]
La riga REDUCED dimostra che la chiamata a toLowerCase() serve: il chiamante può scrivere il tipo in maiuscolo e ottiene l'aliquota ridotta del 12%. La terza riga mostra il difetto: il valore superridotta, che non corrisponde a niente, ottiene in silenzio l'aliquota ordinaria del 21%. Il ternario non ha un ramo per l'ingresso ignoto, e un errore di battitura del chiamante diventa un calcolo sbagliato senza nessun segnale.
È esattamente il tipo di difetto che un tipo enumerato impedirebbe per costruzione, e che un value object del dominio rifiuterebbe nel costruttore. Qui il parametro è una String annotata con niente, e la conseguenza è che la convalida di quel campo non esiste in nessuno dei quattro strati.
I DTO di richiesta e di risposta, e perché sono due
Nel progetto esistono due famiglie separate di oggetti di trasferimento, sotto application/dto/request e application/dto/response, e non si incontrano mai. La tentazione di usare lo stesso record per entrambe le direzioni è forte quando i campi coincidono, e produce un difetto preciso: campi che il client non dovrebbe poter scrivere diventano scrivibili.
Il record di richiesta ceco lo mostra bene. Porta due campi, e il primo è annotato con tre vincoli di Bean Validation: @NotNull, @DecimalMin("0") e @DecimalMax("100000000"). Quelle annotazioni non fanno niente da sole: si attivano quando il parametro del controller è marcato @Valid, e il framework solleva una eccezione che il gestore centrale traduce in un 400 con l'elenco dei campi che non vanno.
| Famiglia | Dove nasce | Chi la convalida | Che cosa non deve contenere |
|---|---|---|---|
| Richiesta | Deserializzata dal corpo HTTP | Bean Validation sul bordo | Campi calcolati dal server, identificatori di altri utenti |
| Risposta | Costruita dal caso d'uso | Nessuno: è già valida | Campi interni, segreti, colonne tecniche |
| Modello di dominio | Costruito dall'adattatore | I propri invarianti | Annotazioni di framework |
Il limite superiore di cento milioni sul campo dell'importo è il tipo di vincolo che sembra arbitrario e invece ha un motivo operativo. Serve a impedire che un valore assurdo arrivi al calcolo e produca un risultato che il formato di risposta non riesce a rappresentare in modo sensato; senza quel tetto il programma accetterebbe qualsiasi numero e l'errore si manifesterebbe più a valle, dove è più difficile capire da dove veniva.
Il criterio, scritto come domanda
Il confronto fra i due casi d'uso dà la regola senza bisogno di appellarsi a nessun principio. L'interfaccia esiste per permettere la sostituzione; se nessuno sostituisce, non sta facendo niente.
- Interfaccia sì quando il caso d'uso dipende da una porta e vuoi poterla sostituire in un test, come nel caso della lista di citazioni.
- Interfaccia sì quando esistono già due implementazioni con la stessa firma, per esempio una gratuita e una a pagamento scelte a runtime.
- Interfaccia no per un calcolo puro che non chiama niente: si istanzia con
newe si verifica direttamente. - Interfaccia no quando il solo scopo è «permettere il mock»: se la classe non ha dipendenze, non c'è niente da simulare.
- Interfaccia no quando la implementi una volta sola e la firma cambia a ogni richiesta di prodotto: due file da aggiornare invece di uno, per nessuna sostituzione.
C'è un costo nascosto nella scelta di non avere l'interfaccia, e vale la pena nominarlo invece di far finta che la decisione sia gratis. Una classe concreta annotata @Service e iniettata direttamente in un controller lega quel controller a un tipo concreto; il giorno in cui ti serve una variante devi cambiare la firma del costruttore del controller, cioè toccare lo strato di presentazione per un motivo che riguarda l'applicazione. Con l'interfaccia quel cambiamento è invisibile al chiamante. Il criterio pratico che ne ricavo è che l'interfaccia vale quanto la probabilità che la variante arrivi, e per un calcolo di aliquote fisse quella probabilità è bassa.
Dove finisce la transazione
Sopra la classe della lista di citazioni ci sono due annotazioni, e la seconda decide una cosa che non si vede leggendo il metodo. La classe è annotata @Service e @Transactional(readOnly = true), e il confine della transazione cade quindi sul caso d'uso, non sull'adattatore e non sul controller.
@Service
@Transactional(readOnly = true)
public class ListQuotesUseCaseImpl implements ListQuotesUseCase { }
@Transactional va importata da org.springframework.transaction.annotation, e qui la precisazione conta: esiste anche jakarta.transaction.Transactional, che Spring onora ma che non accetta l'attributo readOnly. Se importi quella sbagliata, l'editor non si lamenta, il codice compila e l'ottimizzazione che credevi di avere non c'è. È uno degli errori più silenziosi che conosca in questo ecosistema.
L'attributo readOnly vale false se non lo scrivi, e metterlo a true fa due cose. Dice al gestore delle transazioni di marcare la connessione come di sola lettura, cosa che PostgreSQL può usare per rifiutare le scritture; e dice a Hibernate di non tenere traccia delle modifiche agli oggetti caricati, perché non ci sarà niente da scrivere. La seconda è quella che si sente: su una pagina di cinquanta entità, il controllo delle modifiche al termine della transazione è lavoro sprecato per definizione.
Il confine messo qui, e non altrove, ha tre conseguenze che vale la pena enumerare perché sono le ragioni della scelta.
- Una chiamata, una transazione. Se il caso d'uso interrogasse due porte, le due letture vedrebbero lo stesso stato del database invece di due istantanee diverse.
- L'adattatore resta componibile. Con il confine sull'adattatore, due letture nello stesso caso d'uso aprirebbero due transazioni, e la coerenza fra le due sarebbe casuale.
- Il controller resta ignaro. Il bordo HTTP non apre transazioni, quindi non può tenerne una aperta mentre serializza la risposta.
Il terzo punto previene un difetto classico: la transazione che resta aperta durante la scrittura del JSON sulla risposta. Quando succede, la connessione al database è occupata per tutto il tempo in cui il server parla con un client lento, e il pool di connessioni si esaurisce sotto carico per un motivo che non c'entra niente con il database. Tenere il confine un livello più in basso del trasporto è il modo strutturale di impedirlo.
L'involucro di risposta, e perché ne basta uno
Tutti gli endpoint di questo progetto restituiscono la stessa forma esterna, un record generico con cinque campi. Serve a dare a chi consuma l'API una strategia di lettura sola, qualunque sia il codice di stato.
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiEnvelope<T>(
boolean success,
T data,
ErrorDetail error,
MetaInfo meta,
AiProvenance provenance
) { }
@JsonInclude(NON_NULL) viene da Jackson e decide che i campi nulli non compaiono nel JSON. È la proprietà che rende questo involucro usabile: senza di essa ogni risposta riuscita porterebbe "error": null, "meta": null e "provenance": null, cioè tre chiavi inutili su ogni risposta di ogni endpoint. Il valore predefinito è ALWAYS, che include tutto.
I metodi di costruzione sono statici e tipizzati, e l'uscita del programma di prova mostra che cosa producono:
envelope ok -> success=true error=null
envelope errore -> success=false data=null code=VALIDATION_ERROR
envelope pagina -> totalPages=3 total=47 size=20
La terza riga è quella che vale la pena guardare. ApiEnvelope.paginated(List.of("a","b","c"), 47, 0, 20) riceve tre elementi, un totale di 47 e una dimensione di 20, e calcola da sé che le pagine sono 3. Il calcolo sta nel costruttore del record annidato PagedData ed è Math.ceil((double) total / size), con una guardia che restituisce zero se la dimensione non è positiva. Averlo lì dentro significa che nessun controller può sbagliarlo, e che non esiste un endpoint in cui il numero di pagine sia calcolato in modo diverso da un altro.
C'è un campo nell'involucro che non riguarda nessuno dei quattro strati: provenance, che porta la marcatura leggibile da una macchina richiesta quando la risposta è stata prodotta con l'aiuto di un sistema automatico. Il commento nel sorgente dice che viene assegnato centralmente dall'advice di risposta e mai dai singoli controller, ed è la scelta giusta: un obbligo che vale per tutte le risposte non si può affidare alla disciplina di 217 controller.
La costruzione passa anche per List.copyOf(items), che crea una copia non modificabile: se il chiamante tiene un riferimento alla lista originale e la modifica dopo, il contenuto della risposta non cambia. È la forma minima di immutabilità che serve quando un oggetto viene costruito in uno strato e serializzato in un altro.
Chiusura
Adesso hai un criterio per decidere se un caso d'uso merita un'interfaccia, e il criterio non è un principio ma una domanda su cosa esiste oggi nel tuo progetto: c'è una porta da sostituire, o una seconda implementazione? Se la risposta è no, la classe concreta è la scelta giusta, e sai anche quale costo stai accettando.
Sai dove mettere le tre responsabilità che lo strato applicativo porta - normalizzare, orchestrare, tradurre - e perché un limite scritto nel caso d'uso protegge chiamanti che ancora non esistono mentre lo stesso limite scritto nel controller protegge solo HTTP.
Hai anche visto un difetto vero, trovato eseguendo il codice: un tipo di aliquota sconosciuto che riceve in silenzio l'aliquota ordinaria. Il prossimo articolo della serie prende lo strumento che trova questa classe di problemi prima di te, lo esegue su 4.445 classi, e mostra il falso verde che lo aveva reso inutile per mesi.
Commenti
Caricamento commenti...
Accedi per commentare